REST API Caching: ETags, Cache-Control, and CDN Patterns
Master HTTP caching for REST APIs. Learn ETags, Cache-Control headers, conditional requests, and CDN integration patterns with practical examples.
What you'll learn
- ✓How HTTP caching works for APIs
- ✓Cache-Control directives explained
- ✓ETags and conditional requests
- ✓CDN caching patterns for APIs
Prerequisites
- •Basic REST API concepts
- •HTTP headers fundamentals
Why Cache API Responses
Every API call costs time and resources. The client waits for the network round trip. The server processes the request, queries the database, serializes the response. Caching eliminates repeated work by storing responses and reusing them when the data has not changed.
A well-cached API can reduce server load by 80% or more, cut response times from hundreds of milliseconds to single digits, and handle traffic spikes without scaling infrastructure.
How HTTP Caching Works
HTTP has built-in caching mechanisms. The server tells clients and intermediaries (proxies, CDNs) how to cache responses using headers. The client can then skip requests entirely or send lightweight validation requests.
There are two caching strategies:
- Expiration-based: The server says “this response is valid for 60 seconds.” The client reuses it without contacting the server.
- Validation-based: The server says “here is a fingerprint for this response.” The client asks “has it changed?” and the server replies with either the new data or a “not modified” status.
Cache-Control Header
The Cache-Control header is the primary mechanism for controlling caching behavior. It appears in both requests and responses.
Response Directives
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600, stale-while-revalidate=60
Content-Type: application/json
{"users": [...]}
Here is what each directive means:
public — Any cache (browser, CDN, proxy) can store this response. Use for data that is not user-specific.
private — Only the end user’s browser can cache this. CDNs and shared proxies must not store it. Use for user-specific data.
max-age=N — The response is fresh for N seconds. During this time, clients use the cached copy without contacting the server.
s-maxage=N — Like max-age but only for shared caches (CDNs, proxies). Overrides max-age for those caches.
no-cache — The cache must revalidate with the server before using the stored response. The response is still cached, but always checked.
no-store — Do not cache the response at all. Use for sensitive data like banking transactions.
stale-while-revalidate=N — Serve stale content for N seconds while fetching a fresh copy in the background.
must-revalidate — Once the response becomes stale, the cache must not use it without revalidating. No serving stale content.
Practical Examples
// Express.js examples
// Public, cacheable for 1 hour
app.get('/api/products', (req, res) => {
res.set('Cache-Control', 'public, max-age=3600');
res.json(products);
});
// Private, user-specific data
app.get('/api/me/profile', authenticateJWT, (req, res) => {
res.set('Cache-Control', 'private, max-age=300');
res.json(req.user.profile);
});
// Never cache - sensitive operations
app.get('/api/me/bank-balance', authenticateJWT, (req, res) => {
res.set('Cache-Control', 'no-store');
res.json({ balance: account.balance });
});
// CDN-friendly with background revalidation
app.get('/api/articles', (req, res) => {
res.set('Cache-Control', 'public, s-maxage=600, stale-while-revalidate=30');
res.json(articles);
});
ETags and Conditional Requests
ETags (entity tags) are fingerprints for a response. They enable validation-based caching where the client asks “has this changed?” and the server can answer with just a status code instead of resending the entire response.
How ETags Work
- The server generates a hash of the response body and sends it as the
ETagheader. - The client stores the response and the ETag.
- On the next request, the client sends
If-None-Matchwith the stored ETag. - If the data has not changed, the server returns
304 Not Modifiedwith no body. - If the data changed, the server returns
200 OKwith the new data and a new ETag.
Implementation
const crypto = require('crypto');
function generateETag(data) {
const json = JSON.stringify(data);
const hash = crypto.createHash('md5').update(json).digest('hex');
return `"${hash}"`;
}
app.get('/api/products/:id', async (req, res) => {
const product = await db.products.findById(req.params.id);
if (!product) {
return res.status(404).json({ error: 'Not found' });
}
const etag = generateETag(product);
// Check if client has a fresh copy
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set('ETag', etag);
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
res.json(product);
});
Weak vs Strong ETags
Strong ETags mean the responses are byte-for-byte identical. Weak ETags (prefixed with W/) mean the responses are semantically equivalent but may differ in formatting.
ETag: "abc123" # Strong: byte-for-byte identical
ETag: W/"abc123" # Weak: semantically equivalent
Use weak ETags when your serialization is not deterministic (e.g., JSON key order may vary).
function generateWeakETag(data) {
// Use a version counter or last-modified timestamp
return `W/"${data.updatedAt.getTime()}"`;
}
Last-Modified and If-Modified-Since
An older but still useful validation mechanism. The server sends a Last-Modified timestamp, and the client sends If-Modified-Since on subsequent requests.
app.get('/api/articles/:id', async (req, res) => {
const article = await db.articles.findById(req.params.id);
const lastModified = article.updatedAt;
// Check if client has a fresh copy
const ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince) {
const clientDate = new Date(ifModifiedSince);
if (lastModified <= clientDate) {
return res.status(304).end();
}
}
res.set('Last-Modified', lastModified.toUTCString());
res.set('Cache-Control', 'public, max-age=60');
res.json(article);
});
ETags are generally preferred because timestamps have second-level precision and can miss rapid changes.
CDN Caching Patterns
A CDN (Content Delivery Network) sits between your clients and your API server. It caches responses at edge locations close to users, reducing latency and offloading traffic from your origin.
Setting Up CDN-Friendly Headers
// Middleware that sets default caching headers
function cdnCacheHeaders(defaultMaxAge = 60) {
return (req, res, next) => {
// Only cache GET requests
if (req.method !== 'GET') {
res.set('Cache-Control', 'no-store');
return next();
}
res.set('Cache-Control', `public, s-maxage=${defaultMaxAge}, stale-while-revalidate=10`);
next();
};
}
app.use('/api/public', cdnCacheHeaders(300));
Vary Header
The Vary header tells caches that the response changes based on certain request headers. Without it, a CDN might serve the wrong cached response.
// Response varies by Accept-Language
app.get('/api/products', (req, res) => {
const lang = req.headers['accept-language'] || 'en';
const products = getLocalizedProducts(lang);
res.set('Vary', 'Accept-Language');
res.set('Cache-Control', 'public, s-maxage=600');
res.json(products);
});
// Response varies by Authorization (each user gets different cache)
app.get('/api/feed', authenticateJWT, (req, res) => {
res.set('Vary', 'Authorization');
res.set('Cache-Control', 'private, max-age=120');
res.json(userFeed);
});
Cache Invalidation with Surrogate Keys
Many CDNs support surrogate keys (also called cache tags). You tag cached responses with keys, then purge all responses with a specific tag when data changes.
app.get('/api/products/:id', async (req, res) => {
const product = await db.products.findById(req.params.id);
res.set('Cache-Control', 'public, s-maxage=86400');
res.set('Surrogate-Key', `product-${product.id} products category-${product.categoryId}`);
res.json(product);
});
// When a product is updated, purge related caches
app.put('/api/products/:id', async (req, res) => {
const product = await db.products.update(req.params.id, req.body);
// Purge CDN cache for this product and related listings
await cdn.purgeByTag(`product-${product.id}`);
await cdn.purgeByTag(`category-${product.categoryId}`);
res.json(product);
});
Cache Key Design
By default, CDNs cache based on the full URL including query parameters. Be deliberate about what parameters affect caching.
// These are different cache entries:
// /api/products?page=1&limit=20
// /api/products?limit=20&page=1 (same data, different cache key!)
// Normalize query parameters to improve cache hit rates
function normalizeQueryParams(req, res, next) {
const sorted = Object.keys(req.query).sort();
const normalized = {};
for (const key of sorted) {
normalized[key] = req.query[key];
}
req.query = normalized;
next();
}
Caching Strategy by Endpoint Type
Different endpoints need different caching strategies:
| Endpoint Type | Cache-Control | ETag | CDN |
|---|---|---|---|
| Public listings | public, s-maxage=300 | Yes | Yes |
| Single resource | public, max-age=60 | Yes | Yes |
| User-specific data | private, max-age=120 | Yes | No |
| Search results | public, s-maxage=60 | No | Maybe |
| Write operations | no-store | No | No |
| Auth endpoints | no-store | No | No |
Debugging Cache Behavior
Use response headers to understand what caching layers are doing:
# Check cache headers
curl -I https://api.example.com/v1/products
# Look for these headers:
# Cache-Control: public, s-maxage=300
# ETag: "abc123"
# Age: 45 (seconds since CDN cached this)
# X-Cache: HIT (CDN served from cache)
# X-Cache: MISS (CDN fetched from origin)
# CF-Cache-Status: HIT (Cloudflare-specific)
Add a cache-status header in your responses to help with debugging:
app.use((req, res, next) => {
res.set('X-Cache-Generated', new Date().toISOString());
next();
});
Common Mistakes
Caching authenticated responses publicly. If your CDN caches a response that includes user-specific data with public, every user will see the first user’s data. Always use private for authenticated endpoints or include Vary: Authorization.
Forgetting to invalidate. Caching is easy. Invalidation is hard. Have a clear strategy for purging stale data when the underlying resource changes.
Over-caching during development. Set short TTLs during development and staging. Nothing wastes more debugging time than stale cached responses.
Ignoring the Vary header. Without Vary, a CDN might serve English content to a French user or return gzipped content to a client that does not support it.
Wrapping Up
HTTP caching is one of the most effective performance optimizations for REST APIs. Use Cache-Control to set expiration policies, ETags for efficient validation, and CDN patterns to serve responses from the edge. Start with conservative TTLs, measure cache hit rates, and increase them as you gain confidence. The best cache strategy is the one you actually monitor and adjust based on real traffic patterns.
Related articles
- REST APIs REST API Pagination Patterns
Compare offset, cursor, and keyset pagination for REST APIs. Pick the right pattern for your data, scale, and client experience.
- REST APIs REST API Authentication: API Keys, JWT, and OAuth 2.0
Learn the three most common REST API authentication methods. Compare API keys, JWT tokens, and OAuth 2.0 with working code examples and security best practices.
- REST APIs REST API Testing: Postman, curl, and Automated Tests
Learn to test REST APIs manually with Postman and curl, then automate with Jest and Supertest. Covers status codes, response validation, and CI integration.
- REST APIs REST vs GraphQL vs gRPC: API Styles Compared
Compare REST, GraphQL, and gRPC for API design. Understand tradeoffs in performance, flexibility, and developer experience to pick the right API style.