Contentful API Optimization: Reducing API Calls
A practical guide to optimizing Contentful API usage covering caching strategies, sync API, includes, request deduplication, and multi-tier caching with Node.js.
Contentful API Optimization: Reducing API Calls
If you are running a production site on Contentful, you will eventually hit the wall. The Content Delivery API (CDA) allows 78 requests per second. The Content Management API (CMA) caps at 10. Those numbers sound generous until your traffic spikes, your editorial team triggers a flurry of publishes, and your server starts hammering the API on every single page load. I have watched a mid-traffic Express.js application burn through 500 API calls per minute doing nothing more than rendering articles and resolving linked entries. That is not sustainable.
This article walks through every technique I know for reducing Contentful API consumption in a Node.js application. We will start with the fundamentals — understanding how the API actually works — and build toward a complete content layer with multi-tier caching, sync-based incremental updates, request deduplication, and webhook-driven cache invalidation. By the end, that 500 calls per minute drops to about 5.
Understanding Contentful Rate Limits
Contentful enforces rate limits per space and per environment. The numbers that matter:
- Content Delivery API (CDA): 78 requests per second
- Content Preview API (CPA): 14 requests per second
- Content Management API (CMA): 10 requests per second
When you exceed these limits, Contentful returns a 429 Too Many Requests response with a X-Contentful-RateLimit-Reset header indicating how many seconds to wait. The official SDK handles retries automatically when retryOnError is enabled, but retrying is not optimizing. You want to eliminate the calls, not retry them.
The first step is measuring. Add logging to your Contentful client to track how many calls you actually make:
var contentful = require("contentful");
var callCount = 0;
var client = contentful.createClient({
space: process.env.CONTENTFUL_SPACE,
accessToken: process.env.CONTENTFUL_TOKEN,
retryOnError: true,
retryLimit: 3,
timeout: 10000
});
// Wrap getEntries to track usage
var originalGetEntries = client.getEntries.bind(client);
client.getEntries = function(query) {
callCount++;
console.log("[Contentful] API call #" + callCount + ":", JSON.stringify(query));
return originalGetEntries(query);
};
setInterval(function() {
console.log("[Contentful] Calls in last minute:", callCount);
callCount = 0;
}, 60000);
Run this in production for a day and you will know exactly where the waste is.
The Includes Parameter: Stop Making Extra Calls
The single biggest mistake I see is developers fetching an entry, then making separate calls to resolve its linked entries. Contentful's include parameter handles this automatically. When you call getEntries, the response contains an includes object with all linked assets and entries resolved up to a specified depth.
// BAD: Multiple calls to resolve references
function getArticleBad(slug) {
return client.getEntries({
content_type: "article",
"fields.slug": slug,
limit: 1
}).then(function(response) {
var article = response.items[0];
// This triggers ANOTHER API call for the author
return client.getEntry(article.fields.author.sys.id);
});
}
// GOOD: Single call with includes
function getArticleGood(slug) {
return client.getEntries({
content_type: "article",
"fields.slug": slug,
include: 2,
limit: 1
});
}
The include parameter accepts values from 0 to 10, representing the depth of linked entries to resolve. The default is 1. Setting it to 2 or 3 covers most content models. Setting it higher than necessary increases payload size without benefit.
The SDK automatically resolves these includes into the entry's fields, so article.fields.author becomes the full author entry object rather than a link reference. This is one of those features that is easy to overlook and expensive to ignore.
The Select Parameter: Reduce Payload Size
Every field you do not need is bandwidth you are wasting. The select parameter lets you specify exactly which fields to return:
// Returns only the fields you need
function getArticleList() {
return client.getEntries({
content_type: "article",
select: "fields.title,fields.slug,fields.synopsis,fields.publishDate,sys.id",
order: "-fields.publishDate",
limit: 20
});
}
On an article listing page, you do not need the full body content. Trimming the response from 500KB to 30KB is not just a bandwidth optimization — it is a latency optimization. Smaller payloads parse faster on both server and client.
One caveat: the sys fields are always included regardless of your select parameter. Use that to your advantage by always having access to sys.id, sys.createdAt, and sys.updatedAt for cache invalidation logic.
Batch Fetching: getEntries Over getEntry
Never call getEntry in a loop. If you need 10 entries by ID, use a single getEntries call with the sys.id[in] operator:
// BAD: 10 API calls
function getEntriesBad(ids) {
return Promise.all(ids.map(function(id) {
return client.getEntry(id);
}));
}
// GOOD: 1 API call
function getEntriesGood(ids) {
return client.getEntries({
"sys.id[in]": ids.join(","),
include: 2
});
}
This pattern applies to every situation where you are resolving multiple references. Tag pages, category listings, related articles — all of these should be single calls with filters, not loops over individual fetches.
The Sync API: Incremental Updates
The Sync API is the most powerful tool Contentful gives you for reducing API calls, and it is the most underused. Instead of fetching your entire content catalog on every request, you fetch only what has changed since your last sync.
The first sync returns everything:
function initialSync() {
return client.sync({
initial: true,
type: "Entry"
}).then(function(response) {
return {
entries: response.entries,
nextSyncToken: response.nextSyncToken
};
});
}
Subsequent syncs use the token from the previous sync and return only created, updated, or deleted entries:
function incrementalSync(syncToken) {
return client.sync({
nextSyncToken: syncToken
}).then(function(response) {
return {
entries: response.entries,
deletedEntries: response.deletedEntries,
nextSyncToken: response.nextSyncToken
};
});
}
This turns a pattern of "fetch 200 entries every time the cache expires" into "fetch 2 entries that changed in the last hour." The difference in API consumption is enormous.
ETags and Conditional Requests
Contentful supports ETags on CDA responses. The SDK does not expose this directly, but if you are making raw HTTP calls (or wrapping the SDK), you can cache the ETag and send it back as an If-None-Match header. If the content has not changed, Contentful returns a 304 Not Modified with no body, which still counts as an API call but reduces bandwidth significantly.
For most applications, the Sync API is a better approach than ETags. ETags help when you are polling for changes to a specific entry. The Sync API helps when you need to know about changes across your entire space.
Complete Working Example: Multi-Tier Content Layer
Here is the content layer I use in production Express.js applications. It combines in-memory LRU caching, Redis as a shared cache tier, the Sync API for incremental updates, request deduplication, and webhook-driven invalidation.
var contentful = require("contentful");
var Redis = require("ioredis");
var LRU = require("lru-cache");
// ============================================
// Configuration
// ============================================
var MEMORY_CACHE_MAX = 500;
var MEMORY_CACHE_TTL = 1000 * 60 * 5; // 5 minutes
var REDIS_CACHE_TTL = 60 * 30; // 30 minutes (seconds)
var SYNC_INTERVAL = 1000 * 60 * 2; // 2 minutes
// ============================================
// Clients
// ============================================
var client = contentful.createClient({
space: process.env.CONTENTFUL_SPACE,
accessToken: process.env.CONTENTFUL_TOKEN,
retryOnError: true,
retryLimit: 3,
timeout: 10000
});
var redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
var memoryCache = new LRU({
max: MEMORY_CACHE_MAX,
ttl: MEMORY_CACHE_TTL
});
// ============================================
// Request Deduplication
// ============================================
var inflightRequests = {};
function dedupedFetch(cacheKey, fetchFn) {
if (inflightRequests[cacheKey]) {
return inflightRequests[cacheKey];
}
inflightRequests[cacheKey] = fetchFn()
.then(function(result) {
delete inflightRequests[cacheKey];
return result;
})
.catch(function(err) {
delete inflightRequests[cacheKey];
throw err;
});
return inflightRequests[cacheKey];
}
// ============================================
// Multi-Tier Cache
// ============================================
function cacheGet(key) {
// Tier 1: Memory
var memResult = memoryCache.get(key);
if (memResult) {
return Promise.resolve({ data: memResult, source: "memory" });
}
// Tier 2: Redis
return redis.get("contentful:" + key).then(function(redisResult) {
if (redisResult) {
var parsed = JSON.parse(redisResult);
memoryCache.set(key, parsed);
return { data: parsed, source: "redis" };
}
return null;
});
}
function cacheSet(key, data) {
memoryCache.set(key, data);
return redis.setex(
"contentful:" + key,
REDIS_CACHE_TTL,
JSON.stringify(data)
);
}
function cacheInvalidate(key) {
memoryCache.delete(key);
return redis.del("contentful:" + key);
}
function cacheInvalidatePattern(pattern) {
memoryCache.clear();
return redis.keys("contentful:" + pattern).then(function(keys) {
if (keys.length === 0) return Promise.resolve();
return redis.del(keys);
});
}
// ============================================
// Sync Engine
// ============================================
var syncState = {
token: null,
entries: {},
lastSync: null
};
function runSync() {
var syncParams;
if (!syncState.token) {
syncParams = { initial: true, type: "Entry" };
} else {
syncParams = { nextSyncToken: syncState.token };
}
return client.sync(syncParams).then(function(response) {
// Process new/updated entries
response.entries.forEach(function(entry) {
syncState.entries[entry.sys.id] = entry;
cacheInvalidate("entry:" + entry.sys.id);
// Invalidate slug-based caches if the entry has a slug
if (entry.fields && entry.fields.slug) {
cacheInvalidate("slug:" + entry.fields.slug);
}
});
// Process deleted entries
if (response.deletedEntries) {
response.deletedEntries.forEach(function(entry) {
delete syncState.entries[entry.sys.id];
cacheInvalidate("entry:" + entry.sys.id);
});
}
// Invalidate listing caches on any change
if (response.entries.length > 0 || (response.deletedEntries && response.deletedEntries.length > 0)) {
cacheInvalidatePattern("list:*");
}
syncState.token = response.nextSyncToken;
syncState.lastSync = new Date();
console.log("[Sync] Completed. Entries updated:", response.entries.length,
"Deleted:", response.deletedEntries ? response.deletedEntries.length : 0);
return syncState;
});
}
// Start sync loop
runSync().then(function() {
setInterval(runSync, SYNC_INTERVAL);
});
// ============================================
// Public API
// ============================================
function getEntry(id, options) {
var cacheKey = "entry:" + id;
var opts = options || {};
return cacheGet(cacheKey).then(function(cached) {
if (cached) return cached.data;
return dedupedFetch(cacheKey, function() {
// Check sync state first
if (syncState.entries[id]) {
var entry = syncState.entries[id];
cacheSet(cacheKey, entry);
return Promise.resolve(entry);
}
// Fallback to API
return client.getEntries({
"sys.id": id,
include: opts.include || 2
}).then(function(response) {
var entry = response.items[0] || null;
if (entry) cacheSet(cacheKey, entry);
return entry;
});
});
});
}
function getEntryBySlug(contentType, slug, options) {
var cacheKey = "slug:" + slug;
var opts = options || {};
return cacheGet(cacheKey).then(function(cached) {
if (cached) return cached.data;
return dedupedFetch(cacheKey, function() {
return client.getEntries({
content_type: contentType,
"fields.slug": slug,
include: opts.include || 2,
limit: 1
}).then(function(response) {
var entry = response.items[0] || null;
if (entry) {
cacheSet(cacheKey, entry);
cacheSet("entry:" + entry.sys.id, entry);
}
return entry;
});
});
});
}
function getEntries(contentType, query) {
var q = query || {};
var cacheKey = "list:" + contentType + ":" + JSON.stringify(q);
return cacheGet(cacheKey).then(function(cached) {
if (cached) return cached.data;
return dedupedFetch(cacheKey, function() {
var params = {
content_type: contentType,
include: q.include || 1,
limit: q.limit || 100,
skip: q.skip || 0
};
if (q.order) params.order = q.order;
if (q.select) params.select = q.select;
// Copy any additional query filters
Object.keys(q).forEach(function(key) {
if (["include", "limit", "skip", "order", "select"].indexOf(key) === -1) {
params[key] = q[key];
}
});
return client.getEntries(params).then(function(response) {
var result = {
items: response.items,
total: response.total,
skip: response.skip,
limit: response.limit
};
cacheSet(cacheKey, result);
return result;
});
});
});
}
// ============================================
// Webhook Handler
// ============================================
function handleWebhook(req, res) {
var topic = req.headers["x-contentful-topic"];
var entryId = req.body && req.body.sys ? req.body.sys.id : null;
console.log("[Webhook] Received:", topic, "Entry:", entryId);
if (entryId) {
cacheInvalidate("entry:" + entryId);
if (req.body.fields && req.body.fields.slug) {
// Handle localized slug field
var slugField = req.body.fields.slug;
var locales = Object.keys(slugField);
locales.forEach(function(locale) {
cacheInvalidate("slug:" + slugField[locale]);
});
}
}
// Force a sync to pick up changes immediately
runSync().then(function() {
res.status(200).json({ received: true });
}).catch(function(err) {
console.error("[Webhook] Sync failed:", err.message);
res.status(500).json({ error: "Sync failed" });
});
}
module.exports = {
getEntry: getEntry,
getEntryBySlug: getEntryBySlug,
getEntries: getEntries,
handleWebhook: handleWebhook,
getSyncState: function() { return syncState; }
};
Integrating With Express.js
Wire this content layer into your Express routes:
var express = require("express");
var router = express.Router();
var content = require("./contentLayer");
// Article listing with caching
router.get("/articles", function(req, res, next) {
var page = parseInt(req.query.page) || 1;
var perPage = 20;
content.getEntries("article", {
order: "-fields.publishDate",
select: "fields.title,fields.slug,fields.synopsis,fields.publishDate,fields.category",
limit: perPage,
skip: (page - 1) * perPage
}).then(function(result) {
res.render("articles", {
articles: result.items,
total: result.total,
page: page,
totalPages: Math.ceil(result.total / perPage)
});
}).catch(next);
});
// Individual article with deep includes
router.get("/articles/:slug", function(req, res, next) {
content.getEntryBySlug("article", req.params.slug, {
include: 3
}).then(function(article) {
if (!article) return res.status(404).render("404");
res.render("article", { article: article });
}).catch(next);
});
// Webhook endpoint
router.post("/webhooks/contentful", function(req, res) {
content.handleWebhook(req, res);
});
module.exports = router;
How the Call Reduction Works
Here is why this drops API calls from 500/min to 5/min:
- Initial sync loads all entries in a single paginated API call sequence (roughly 2-3 calls for 200 entries).
- Incremental syncs every 2 minutes make exactly 1 API call each, returning only changed entries.
- In-memory LRU handles repeated requests for the same content within a 5-minute window with zero API calls.
- Redis handles cache hits across multiple Node.js processes or after a process restart, again with zero API calls.
- Request deduplication prevents the thundering herd problem — 50 simultaneous requests for the same uncached article result in 1 API call, not 50.
- Webhooks trigger immediate cache invalidation and a forced sync, so content updates appear without waiting for the next sync interval.
The remaining 5 calls per minute come from the sync interval (1 call every 2 minutes) plus occasional cache misses for rarely accessed content.
Pagination: Skip/Limit vs. Sync
Contentful's default pagination uses skip and limit. The maximum limit is 1000, and skip has a hard cap at 1000 as well, meaning you cannot paginate past 1000 entries with skip/limit alone.
For content sets larger than 1000 entries, use the Sync API for the initial load (it handles pagination internally), then maintain the full set in your cache. For client-facing pagination, paginate over your cached data rather than making API calls per page:
function getPaginatedArticles(page, perPage) {
var cacheKey = "all-articles";
return cacheGet(cacheKey).then(function(cached) {
if (cached) return paginateLocally(cached.data, page, perPage);
return client.getEntries({
content_type: "article",
order: "-fields.publishDate",
select: "fields.title,fields.slug,fields.synopsis,fields.publishDate",
limit: 1000
}).then(function(response) {
cacheSet(cacheKey, response.items);
return paginateLocally(response.items, page, perPage);
});
});
}
function paginateLocally(items, page, perPage) {
var start = (page - 1) * perPage;
return {
items: items.slice(start, start + perPage),
total: items.length,
page: page,
totalPages: Math.ceil(items.length / perPage)
};
}
This approach makes one API call to load all articles, then serves every subsequent page request from cache.
CDN Caching and Contentful's Architecture
Contentful's CDA is served through a CDN (Fastly). Responses include Cache-Control headers, and the CDN caches content at edge nodes. However, each unique query string creates a separate cache entry at the CDN level. This means:
?content_type=article&limit=10and?content_type=article&limit=20are two separate cache entries- Consistent query parameters improve CDN cache hit rates
- The CDN does not help if your queries are highly dynamic (unique filters per user)
Normalize your queries to maximize CDN hits. Always use the same field order, the same limit values, and avoid per-request variations in query structure.
Pre-Fetching and Cache Warming
For content you know will be requested, warm the cache proactively:
function warmCache() {
console.log("[Cache] Warming started");
return Promise.all([
// Warm the homepage articles
content.getEntries("article", {
order: "-fields.publishDate",
limit: 10,
select: "fields.title,fields.slug,fields.synopsis,fields.publishDate"
}),
// Warm category pages
content.getEntries("category", { limit: 100 }),
// Warm navigation data
content.getEntries("navigationItem", { include: 2, limit: 50 })
]).then(function() {
console.log("[Cache] Warming complete");
});
}
// Warm on startup
warmCache();
This eliminates cold-start latency for your most important pages.
Common Issues and Troubleshooting
1. Cache Stampede on Expiration
When a popular cache entry expires, dozens of requests simultaneously hit the API. The request deduplication layer in the content layer above solves this. Without it, a single cache expiration for your homepage data could generate 30+ duplicate API calls in under a second.
2. Stale Content After Publish
Content editors publish an update and it does not appear on the site. This happens when your cache TTL is too long and you are not using webhooks. The fix is the webhook handler shown above — Contentful sends a webhook on publish, your handler invalidates the specific cache entry and triggers an immediate sync.
3. Sync Token Expiration
Contentful sync tokens expire after approximately 90 days of inactivity. If your application restarts after an extended downtime and the stored token is expired, the sync call will fail. Always handle this gracefully:
function runSyncSafe() {
return runSync().catch(function(err) {
if (err.sys && err.sys.id === "InvalidSyncToken") {
console.warn("[Sync] Token expired, performing full re-sync");
syncState.token = null;
syncState.entries = {};
return runSync();
}
throw err;
});
}
4. Memory Bloat with Large Content Sets
Storing thousands of full entries in memory will bloat your Node.js process. Set appropriate max values on your LRU cache and consider storing only essential fields in the sync state. For sites with 10,000+ entries, use Redis as the primary cache and keep the memory cache small (100-200 entries).
5. Redis Connection Failures
When Redis goes down, your entire content layer should not go down with it. Add fallback logic that bypasses Redis and goes straight to the API:
function cacheGetSafe(key) {
var memResult = memoryCache.get(key);
if (memResult) {
return Promise.resolve({ data: memResult, source: "memory" });
}
return redis.get("contentful:" + key)
.then(function(result) {
if (result) {
var parsed = JSON.parse(result);
memoryCache.set(key, parsed);
return { data: parsed, source: "redis" };
}
return null;
})
.catch(function(err) {
console.warn("[Cache] Redis error, skipping:", err.message);
return null;
});
}
Best Practices
Always use
content_typein queries. Without it, Contentful searches across all content types, which is slower and returns irrelevant data. EverygetEntriescall should specify the content type.Set
includeintentionally. Do not default toinclude: 10. Each level of depth increases payload size and response time. Profile your content model and use the minimum depth needed.Use
selecton listing pages. Article bodies can be tens of kilobytes each. A listing page rendering 20 article cards with full body content wastes 95% of the transferred data.Deduplicate requests at the application layer. Middleware, layout partials, and widgets may independently request the same data on a single page render. Deduplication ensures this results in one API call, not four.
Store sync tokens persistently. If your application restarts, a persisted sync token means you perform an incremental sync instead of a full initial sync. Store the token in Redis or a file:
var fs = require("fs"); var SYNC_TOKEN_FILE = "./sync-token.json"; function persistSyncToken(token) { fs.writeFileSync(SYNC_TOKEN_FILE, JSON.stringify({ token: token })); } function loadSyncToken() { try { var data = JSON.parse(fs.readFileSync(SYNC_TOKEN_FILE, "utf8")); return data.token; } catch (e) { return null; } }Monitor and alert on rate limit responses. Even with caching, edge cases can trigger rate limits. Log every 429 response and set up alerts so you catch problems before your users do.
Separate read-heavy and write-heavy operations. Use the CDA for all public-facing reads and reserve the CMA for editorial tools. Never mix them in the same request path — the CMA's 10 req/s limit will bottleneck your entire application.
Consider static generation for stable content. If your content changes less than once per hour, building static HTML files on publish (via webhooks) eliminates runtime API calls entirely. Tools like a simple build script triggered by a Contentful webhook can regenerate affected pages in seconds.