JSON API HTTP Caching: ETag, Cache-Control, CDN & Conditional Requests
Last updated:
Most JSON API performance guides focus on Redis and application-layer caching. The HTTP layer is often overlooked — but a correctly set Cache-Control header eliminates origin requests entirely, a 304 Not Modified saves 100% of body transfer cost, and a CDN with surrogate key invalidation serves millions of JSON responses per second without touching your database. This guide covers every HTTP caching mechanism relevant to JSON APIs: Cache-Control directive semantics, ETag and Last-Modified conditional requests, the Vary header and its cache fragmentation risks, CDN-specific caching configuration for Cloudflare and Fastly, event-driven cache invalidation, and Next.js Route Handler cache configuration. Every section includes working code.
HTTP Caching Fundamentals for JSON APIs
HTTP caching is controlled by the Cache-Control response header. For JSON APIs, the directive combination determines where the response is cached (browser, CDN, shared proxy), for how long, and under what conditions it can be served stale. Getting this right reduces origin load by 90%+ on read-heavy APIs without any application code change.
// ── Cache-Control directive reference ─────────────────────────────────
// PUBLIC: any cache (browser, CDN, proxy) may store the response
// Use for: unauthenticated JSON APIs, static data, product catalogs
Cache-Control: public, max-age=300
// PRIVATE: only the browser may store; CDNs/proxies must not cache
// Use for: user-specific JSON (profile, cart, notifications)
Cache-Control: private, max-age=60
// NO-STORE: do not cache at any layer — fetches always go to origin
// Use for: OTP responses, payment confirmations, one-time tokens
Cache-Control: no-store
// NO-CACHE: may store but MUST revalidate before serving
// Sends 304 Not Modified if ETag matches — still saves body transfer
// Use for: data where staleness is unacceptable but ETags are set
Cache-Control: no-cache
// max-age: browser (and CDN) cache TTL in seconds
Cache-Control: public, max-age=300 // 5 minutes
// s-maxage: CDN-only TTL — overrides max-age for shared caches
// Browsers use max-age; CDNs use s-maxage
Cache-Control: public, max-age=60, s-maxage=3600 // browser: 1m, CDN: 1h
// stale-while-revalidate: serve stale for N seconds while fetching fresh
// Zero perceived latency — the stale response is served instantly
Cache-Control: public, max-age=300, stale-while-revalidate=60
// stale-if-error: serve stale for N seconds when origin returns 5xx
// Keeps the API available during origin outages
Cache-Control: public, max-age=300, stale-if-error=86400
// ── Express.js: setting Cache-Control on JSON responses ───────────────
import express from 'express'
const app = express()
// Public API endpoint — 5 min cache, 1 min stale-while-revalidate
app.get('/api/products', (req, res) => {
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=60')
res.json({ products: [...] })
})
// User-specific endpoint — browser-only cache, no CDN
app.get('/api/me', authenticate, (req, res) => {
res.set('Cache-Control', 'private, max-age=60')
res.json({ user: req.user })
})
// Real-time endpoint — never cache
app.get('/api/live-price', (req, res) => {
res.set('Cache-Control', 'no-store')
res.json({ price: getLivePrice() })
})
// ── Next.js Route Handler ─────────────────────────────────────────────
// app/api/products/route.ts
export async function GET() {
const products = await fetchProducts()
return Response.json(products, {
headers: {
'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=60',
},
})
}
// ── Choosing the right directive combination ──────────────────────────
// | Use case | Directive |
// | Public catalog, rarely changes| public, max-age=3600, s-maxage=86400 |
// | Public, changes frequently | public, max-age=60, stale-while-revalidate=30 |
// | Per-user (no CDN) | private, max-age=300 |
// | Must revalidate (ETag flow) | no-cache (+ ETag header) |
// | Never cache | no-store |The most impactful directive for high-traffic JSON APIs is stale-while-revalidate. Without it, every request that arrives just after max-age expiry blocks waiting for the origin — users experience a latency spike. With it, every request gets a cached response and the revalidation happens asynchronously. Pair s-maxage with max-age to give CDN edges a longer TTL than browsers, reducing origin fan-out while keeping browser caches fresh for interactive use. See the JSON caching strategies guide for Redis and application-layer caching patterns that complement HTTP caching.
ETag-Based Conditional Requests
An ETag (entity tag) is a fingerprint of the response body — typically an MD5 or SHA-1 hash of the JSON content. When a client has a cached response with an ETag, it sends If-None-Match: "hash" on subsequent requests. If the content has not changed, the server responds with 304 Not Modified — no body, just headers — saving 100% of the body transfer cost. For a 500KB JSON response hit 10,000 times per minute, ETags can save 5GB of bandwidth per minute when data is unchanged.
import { createHash } from 'node:crypto'
// ── Generating an ETag from JSON content ──────────────────────────────
function generateETag(data: unknown): string {
const body = JSON.stringify(data)
const hash = createHash('sha1').update(body).digest('hex')
return `"${hash}"` // strong ETag — byte-for-byte identical
// return `W/"${hash}"` // weak ETag — semantically equivalent
}
// ── Express: manual ETag + conditional GET ────────────────────────────
app.get('/api/products', async (req, res) => {
const products = await db.getProducts()
const etag = generateETag(products)
// Check If-None-Match — return 304 if content unchanged
if (req.headers['if-none-match'] === etag) {
res.status(304).end() // no body sent — saves full transfer
return
}
res.set({
'ETag': etag,
'Cache-Control': 'public, max-age=300',
'Content-Type': 'application/json',
})
res.json(products)
})
// ── Express: automatic ETag via app.set('etag') ───────────────────────
// Express computes ETags automatically and sends 304 when they match
app.set('etag', 'strong') // strong: SHA-1 of body (default)
// app.set('etag', 'weak') // weak: W/ prefix, semantic equivalence
// app.set('etag', false) // disable automatic ETags
// When etag is enabled, Express adds ETag header automatically and
// handles If-None-Match comparison — no manual code needed:
app.get('/api/products', async (req, res) => {
const products = await db.getProducts()
res.set('Cache-Control', 'public, max-age=300')
res.json(products) // ETag computed and 304 returned automatically
})
// ── Next.js Route Handler: manual ETag ───────────────────────────────
// app/api/products/route.ts
export async function GET(request: Request) {
const products = await fetchProducts()
const body = JSON.stringify(products)
const hash = createHash('sha1').update(body).digest('hex')
const etag = `"${hash}"`
// Check conditional request
const ifNoneMatch = request.headers.get('if-none-match')
if (ifNoneMatch === etag) {
return new Response(null, { status: 304 })
}
return new Response(body, {
headers: {
'Content-Type': 'application/json',
'ETag': etag,
'Cache-Control': 'public, max-age=300',
},
})
}
// ── HTTP flow walkthrough ──────────────────────────────────────────────
// Request 1 (no cache):
// GET /api/products
// → 200 OK ETag: "a1b2c3" Cache-Control: public, max-age=300
// Body: {"products": [...]} (full transfer)
//
// Request 2 (within max-age):
// Served from browser/CDN cache — origin not contacted
//
// Request 3 (after max-age expires):
// GET /api/products If-None-Match: "a1b2c3"
// → 304 Not Modified ETag: "a1b2c3"
// Body: (empty — zero transfer cost)
// Browser serves cached body with refreshed TTL
//
// Request 4 (after data changes):
// GET /api/products If-None-Match: "a1b2c3"
// → 200 OK ETag: "d4e5f6" (new hash)
// Body: {"products": [...]} (new data transferred)
// ── Strong vs weak ETags ──────────────────────────────────────────────
// Strong ETag: "a1b2c3" — byte-identical responses only
// Use when: JSON serialization is deterministic (stable key order)
//
// Weak ETag: W/"a1b2c3" — semantically equivalent responses
// Use when: response may vary in whitespace, key order, or
// non-semantic fields (e.g., request-id header differs)
//
// Note: JSON.stringify() key order depends on insertion order in V8 —
// sort your object keys before hashing for deterministic ETags:
function deterministicETag(data: unknown): string {
const sorted = JSON.parse(JSON.stringify(data, Object.keys(data as object).sort()))
return `"${createHash('sha1').update(JSON.stringify(sorted)).digest('hex')}"`
}A subtle issue: JSON.stringify() key ordering depends on insertion order in V8 — if your data comes from a database query with non-deterministic column ordering, two identical datasets might produce different JSON strings and therefore different ETags. Sort object keys before hashing to guarantee deterministic ETags. For Express, the built-in app.set('etag', 'strong') handles this automatically because it hashes the final serialized string. For JSON API design patterns including versioning and content negotiation, see the linked guide.
Last-Modified Conditional Requests
Last-Modified is the timestamp-based alternative to ETag for conditional requests. The server sets Last-Modified: Wed, 11 Feb 2026 12:00:00 GMT on the response; the client sends If-Modified-Since: Wed, 11 Feb 2026 12:00:00 GMT on subsequent requests. The server returns 304 if the resource has not changed since that timestamp. Last-Modified is simpler to implement than ETag when your data has reliable updated_at timestamps.
// ── Express: Last-Modified conditional GET ───────────────────────────
app.get('/api/products', async (req, res) => {
// Fetch the most recently updated record timestamp
const { rows } = await db.query(
'SELECT MAX(updated_at) AS last_modified FROM products'
)
const lastModified = new Date(rows[0].last_modified)
// Check If-Modified-Since header
const ifModifiedSince = req.headers['if-modified-since']
if (ifModifiedSince) {
const clientDate = new Date(ifModifiedSince)
if (lastModified <= clientDate) {
res.status(304).end() // content unchanged — no body transfer
return
}
}
const products = await db.query('SELECT * FROM products ORDER BY id')
res.set({
'Last-Modified': lastModified.toUTCString(),
'Cache-Control': 'public, max-age=300',
'Content-Type': 'application/json',
})
res.json(products.rows)
})
// ── Sending both ETag and Last-Modified ───────────────────────────────
// Best practice: set both headers — HTTP/1.0 clients use Last-Modified,
// HTTP/1.1+ clients prefer ETag (more precise, sub-second resolution)
app.get('/api/article/:id', async (req, res) => {
const article = await db.getArticle(req.params.id)
if (!article) { res.status(404).json({ error: 'Not found' }); return }
const lastModified = new Date(article.updated_at)
const etag = generateETag(article)
// Check ETag first (takes precedence over Last-Modified per RFC 7232)
const ifNoneMatch = req.headers['if-none-match']
if (ifNoneMatch && ifNoneMatch === etag) {
res.status(304).end(); return
}
// Check Last-Modified as fallback
const ifModifiedSince = req.headers['if-modified-since']
if (ifModifiedSince && !ifNoneMatch) {
const clientDate = new Date(ifModifiedSince)
if (lastModified <= clientDate) {
res.status(304).end(); return
}
}
res.set({
'ETag': etag,
'Last-Modified': lastModified.toUTCString(),
'Cache-Control': 'public, max-age=600',
'Content-Type': 'application/json',
})
res.json(article)
})
// ── When to use ETag vs Last-Modified ────────────────────────────────
// Use ETag when:
// - Resource can change multiple times per second (sub-second precision)
// - Content-addressable: same data = same fingerprint, always
// - Computed/aggregated JSON (no single updated_at timestamp)
// - You need strong validation for range requests
//
// Use Last-Modified when:
// - Resource maps to a DB record with a reliable updated_at column
// - Simpler implementation without hashing overhead
// - Broad compatibility with HTTP/1.0 proxies
//
// HTTP precedence (RFC 7232 §6):
// 1. If-None-Match (ETag) takes precedence over If-Modified-Since
// 2. If a client sends both, the server must satisfy both conditions
//
// ── Pitfall: 1-second resolution of Last-Modified ─────────────────────
// HTTP date headers have 1-second granularity:
// Last-Modified: Wed, 11 Feb 2026 12:00:00 GMT ← no milliseconds
//
// If a resource changes twice within 1 second, Last-Modified cannot
// distinguish the two states → stale data served to clients.
// Solution: use ETag (content hash) for write-heavy resources.When your database table has an updated_at column, Last-Modified is the lowest-cost conditional caching implementation — a single MAX(updated_at) query tells you whether any data has changed, before fetching the full result set. Combine it with ETag for complete coverage: the ETag check runs first (RFC 7232 §6), and Last-Modified serves as a fallback for older clients. For JSON performance patterns including database query caching, see the linked guide.
Vary Header for JSON Content Negotiation
The Vary header lists request headers that must be identical for a cached response to be reusable. It solves the problem of caches serving a gzip-compressed JSON response to a client that does not support gzip — or serving an English-language response to a French-language client. But every value added to Vary multiplies the number of cache entries, reducing cache hit rates on CDNs.
// ── Safe: Vary: Accept-Encoding ───────────────────────────────────────
// Always set this if you serve gzip/brotli-compressed JSON.
// CDN stores two entries: one for compressed, one for uncompressed.
// Almost all clients send consistent Accept-Encoding values.
res.set({
'Content-Type': 'application/json',
'Content-Encoding': 'gzip',
'Vary': 'Accept-Encoding', // safe — low fragmentation
'Cache-Control': 'public, max-age=300',
})
// ── Risky: Vary: Accept ───────────────────────────────────────────────
// Creates separate cache entries for each unique Accept header value:
// application/json
// application/json; charset=utf-8
// application/ld+json
// */*
// application/json, text/plain, */* (axios default)
// Result: cache fragmented into many entries → CDN hit rate plummets.
//
// Better alternative: always respond with application/json regardless
// of Accept header (JSON APIs rarely need true content negotiation).
// If you serve multiple formats, use separate URL paths:
// /api/products → JSON (always)
// /api/products.jsonld → JSON-LD
// /api/products.xml → XML
// ── Harmful: Vary: Authorization ─────────────────────────────────────
// Each unique Authorization token = separate CDN cache entry.
// With 10,000 users, that's 10,000 cache entries for the same data.
// CDN cache fills with per-user entries — effective hit rate near 0%.
//
// Correct approach for authenticated JSON responses:
res.set('Cache-Control', 'private, max-age=60') // CDNs skip entirely
// ── Harmful: Vary: Cookie ─────────────────────────────────────────────
// Session cookies contain per-user IDs → same fragmentation as Authorization.
// Cloudflare ignores cookies in CDN cache key by default (strips them).
// Never set Vary: Cookie for JSON API responses.
// ── Vary: Accept-Language — fragmentation risk ────────────────────────
// If your JSON API serves localized content based on Accept-Language,
// each language variant creates a separate cache entry:
// en-US, en-GB, fr-FR, de-DE, ...
// Multiplied by your endpoint count = massive cache fragmentation.
//
// Better alternative: URL-based locale routing (CDN-friendly):
// /api/en/products — one cache entry per endpoint per locale
// /api/fr/products
// Or: serve untranslated keys in JSON; translate in the client.
// ── Express middleware: safe Vary configuration ───────────────────────
app.use((req, res, next) => {
// Automatically set Vary: Accept-Encoding for compressed responses
res.vary('Accept-Encoding')
next()
})
// ── Cloudflare: cache key normalization ──────────────────────────────
// Cloudflare can normalize the cache key to ignore certain headers,
// reducing fragmentation from minor Accept-Encoding variations:
//
// In your Cloudflare Cache Rule, set:
// Cache key → Ignore query string: off
// Cache key → Sort query string: on (prevents ?a=1&b=2 vs ?b=2&a=1)
// Cache key → Ignore headers: Accept, Accept-Language (if uniform response)
// ── Debugging Vary fragmentation ──────────────────────────────────────
// Check if Cloudflare is caching your JSON:
// curl -I https://api.example.com/products
// CF-Cache-Status: HIT ← served from CDN edge
// CF-Cache-Status: MISS ← origin was contacted
// CF-Cache-Status: BYPASS ← caching disabled (check Cache-Control)
// CF-Cache-Status: DYNAMIC ← not configured for cachingThe practical rule for JSON APIs: set Vary: Accept-Encoding when you compress responses (always do this), and avoid every other Vary value unless you have a specific content negotiation requirement. If you must serve different JSON formats, use URL routing instead of Vary: Accept. For authenticated endpoints, use Cache-Control: private — never try to CDN-cache per-user JSON by adding Vary: Authorization.
CDN Caching for JSON APIs
CDNs cache JSON API responses at edge nodes geographically close to users, reducing both latency and origin load. The two most common CDNs for API caching are Cloudflare and Fastly. Each has specific configuration requirements for JSON endpoints — neither caches API responses by default.
// ══ CLOUDFLARE ════════════════════════════════════════════════════════
// Step 1: Create a Cache Rule (Rules > Cache Rules) in Cloudflare dashboard
// OR use the Cloudflare API:
//
// POST /zones/{zone_id}/rulesets/phases/http_request_cache_settings/entrypoints
// {
// "rules": [{
// "expression": "(http.request.uri.path matches "^/api/")",
// "action": "set_cache_settings",
// "action_parameters": {
// "cache": true,
// "edge_ttl": { "mode": "override_origin", "default": 300 }
// }
// }]
// }
// Step 2: Set Cache-Control headers from your origin
// s-maxage controls the CDN TTL; max-age controls the browser TTL
res.set('Cache-Control', 'public, max-age=60, s-maxage=3600')
// → Browser caches for 1 minute; Cloudflare edge caches for 1 hour
// Step 3: Add CF-Cache-Tag for surrogate key invalidation
// Tag your JSON responses with resource identifiers
app.get('/api/products', async (req, res) => {
const products = await db.getProducts()
res.set({
'Cache-Control': 'public, max-age=60, s-maxage=3600',
'CF-Cache-Tag': 'products,catalog', // comma-separated tags
})
res.json(products)
})
app.get('/api/products/:id', async (req, res) => {
const product = await db.getProduct(req.params.id)
res.set({
'Cache-Control': 'public, max-age=60, s-maxage=3600',
'CF-Cache-Tag': `product-${req.params.id},products`,
})
res.json(product)
})
// Purge by cache tag when a product is updated:
async function purgeProductFromCloudflare(productId: string) {
await fetch(
`https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/purge_cache`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CF_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ tags: [`product-${productId}`, 'products'] }),
}
)
}
// ── Cloudflare Workers: fine-grained cache control ────────────────────
// wrangler.toml + cache API in Workers:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const cache = caches.default
const cacheKey = new Request(request.url, request)
// Check CDN cache first
let response = await cache.match(cacheKey)
if (response) return response
// Fetch from origin
response = await fetch(request)
const data = await response.json()
// Store in CDN cache with Cache-Control
const cachedResponse = Response.json(data, {
headers: {
'Cache-Control': 'public, max-age=300',
'CF-Cache-Tag': 'products',
},
})
await cache.put(cacheKey, cachedResponse.clone())
return cachedResponse
},
}
// ══ FASTLY ════════════════════════════════════════════════════════════
// Fastly uses Surrogate-Control (not Cache-Control) for CDN TTL:
// Surrogate-Control is stripped before forwarding to the browser,
// so it does not affect client-side caching.
res.set({
'Surrogate-Control': 'max-age=3600', // Fastly CDN TTL: 1 hour
'Cache-Control': 'public, max-age=60', // Browser TTL: 1 min
'Surrogate-Key': 'products product-123', // space-separated tags
'Content-Type': 'application/json',
})
// Purge by surrogate key via Fastly API:
async function purgeProductFromFastly(productId: string) {
await fetch(
`https://api.fastly.com/service/${process.env.FASTLY_SERVICE_ID}/purge/product-${productId}`,
{
method: 'POST',
headers: { 'Fastly-Key': process.env.FASTLY_API_KEY! },
}
)
}
// Purge multiple tags at once (Fastly batch purge):
async function batchPurgeFastly(tags: string[]) {
await fetch(
`https://api.fastly.com/service/${process.env.FASTLY_SERVICE_ID}/purge`,
{
method: 'POST',
headers: {
'Fastly-Key': process.env.FASTLY_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({ surrogate_keys: tags }),
}
)
}
// ── s-maxage vs max-age: the key distinction ─────────────────────────
// max-age: applies to ALL caches (browser + CDN + proxies)
// s-maxage: applies ONLY to shared caches (CDN + proxies)
// overrides max-age for shared caches when present
//
// Example: public, max-age=60, s-maxage=3600
// Browser: caches for 60 seconds (max-age)
// Cloudflare: caches for 3600 seconds (s-maxage overrides max-age)
//
// Use s-maxage to give CDN edges a longer TTL without forcing browsers
// to cache stale data for too long.The combination of s-maxage for long CDN TTLs plus surrogate key invalidation is the production pattern for high-traffic JSON APIs. The CDN serves responses for hours without touching your origin; when data changes, a single API call purges all cached variants by tag in under a second. This eliminates the TTL tradeoff — you do not need a short s-maxage to keep data fresh because you invalidate proactively on write.
Cache Invalidation Strategies for JSON APIs
Phil Karlton's observation that cache invalidation is one of the two hard problems in computer science is particularly true for distributed JSON APIs. HTTP caches at the browser, CDN, and reverse proxy layer all hold copies of your JSON responses. When data changes, you need a strategy to invalidate the right copies — without either serving stale data or flooding your origin with requests.
// ── Strategy 1: TTL-based expiry (simplest) ───────────────────────────
// Set max-age to the acceptable staleness window.
// No active invalidation needed — caches self-heal on TTL expiry.
//
// Tradeoff: data can be stale for up to max-age seconds after a write.
// Good for: semi-static data (product descriptions, article content)
// Bad for: inventory counts, prices, user-generated content
res.set('Cache-Control', 'public, max-age=300') // stale up to 5 min
// ── Strategy 2: stale-while-revalidate (zero-latency TTL) ─────────────
// Extends TTL-based expiry with background revalidation:
// - Within max-age: serve cached response instantly
// - Within max-age + stale-while-revalidate: serve stale + revalidate async
// - After that window: block on origin (synchronous revalidation)
//
// Eliminates the "thundering herd" on TTL expiry — no user ever waits
// for a synchronous revalidation.
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
// Effectively: fresh for 60s; stale-but-acceptable for 5 more minutes
// ── Strategy 3: Surrogate key / cache tag invalidation ─────────────────
// Tag responses with resource IDs. On write, purge by tag.
// This is the gold standard for JSON APIs with mutable data.
//
// Implementation flow:
// 1. On GET /api/products/123 → respond with CF-Cache-Tag: product-123,products
// 2. On PUT /api/products/123 → purge tags product-123 and products
// 3. CDN immediately serves fresh data for all endpoints tagged with those keys
// Database update + cache invalidation in one transaction:
async function updateProduct(id: string, patch: Partial<Product>) {
// 1. Write to database
const updated = await db.products.update({ where: { id }, data: patch })
// 2. Invalidate CDN cache by surrogate key (non-blocking)
await Promise.all([
purgeCloudflareByTag(`product-${id}`), // /api/products/123
purgeCloudflareByTag('products'), // /api/products (list)
purgeCloudflareByTag('featured'), // /api/featured (if applicable)
])
return updated
}
// ── Strategy 4: Event-driven invalidation ─────────────────────────────
// Use a message queue or database change stream to trigger cache purges.
// Decouples cache invalidation from the write path — more resilient.
// Example: Postgres LISTEN/NOTIFY → cache invalidation worker
import { Client } from 'pg'
const client = new Client({ connectionString: process.env.DATABASE_URL })
await client.connect()
await client.query('LISTEN product_changes')
client.on('notification', async (msg) => {
const { id, operation } = JSON.parse(msg.payload ?? '{}')
if (operation === 'UPDATE' || operation === 'DELETE') {
await purgeProductFromCloudflare(id)
console.log(`Purged CDN cache for product ${id}`)
}
})
// Postgres trigger that fires on product changes:
// CREATE OR REPLACE FUNCTION notify_product_change()
// RETURNS trigger AS $$
// BEGIN
// PERFORM pg_notify('product_changes',
// json_build_object('id', NEW.id, 'operation', TG_OP)::text);
// RETURN NEW;
// END;
// $$ LANGUAGE plpgsql;
//
// CREATE TRIGGER product_change_trigger
// AFTER INSERT OR UPDATE OR DELETE ON products
// FOR EACH ROW EXECUTE FUNCTION notify_product_change();
// ── Strategy 5: Versioned URLs (permanent caching) ────────────────────
// Append a content version to the URL — cache forever, invalidate by
// deploying a new URL. Used for JSON configuration files, API schemas.
//
// /api/config.json?v=abc123
// Cache-Control: public, max-age=31536000, immutable
//
// On config change: generate new hash, update references to new URL.
// Old URL remains cached; new URL is fetched fresh.
// ── stale-while-revalidate in Next.js fetch() ─────────────────────────
// next: { revalidate } is Next.js's ISR equivalent of stale-while-revalidate:
const products = await fetch('/api/products', {
next: { revalidate: 60 }, // serve cached for 60s, then revalidate
})
// Or tag for on-demand revalidation:
const products = await fetch('/api/products', {
next: { tags: ['products'] },
})
// In a Server Action or Route Handler:
import { revalidateTag } from 'next/cache'
revalidateTag('products') // purge all fetch() calls tagged 'products'Surrogate key invalidation combined with event-driven triggers is the most operationally sound approach for production JSON APIs — it gives you long CDN TTLs (hours) with near-instant invalidation on write (under 1 second). The TTL floor ensures eventual consistency even if the invalidation event is lost. For JSON API error handling patterns including graceful degradation when the origin is unavailable, see the linked guide.
Next.js JSON API Caching
Next.js App Router has its own multi-layer data cache built on top of HTTP caching. Route Handlers can set standard HTTP Cache-Control headers for CDN and browser caching, while the Next.js data cache (driven by fetch() options) operates at the server layer. Understanding both layers — and how they interact — is essential for correct caching behavior.
// ── Route Handler: HTTP cache headers (browser + CDN) ────────────────
// app/api/products/route.ts
export async function GET(request: Request) {
const products = await db.getProducts()
return Response.json(products, {
headers: {
// CDN caches for 1 hour; browser caches for 1 minute
'Cache-Control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=60',
},
})
}
// ── Route segment config: Next.js server-side caching ────────────────
// These constants control Next.js's own data cache for the route segment,
// NOT the HTTP Cache-Control header sent to the browser/CDN.
export const dynamic = 'force-static' // cache the Route Handler response
export const revalidate = 300 // revalidate every 5 minutes (ISR)
// export const dynamic = 'force-dynamic' // never cache (like no-store)
// ── fetch() cache options: Next.js data cache ─────────────────────────
// Inside Server Components and Route Handlers, fetch() is patched by Next.js
// to support its own data cache.
// Force-cache: cache indefinitely (Next.js default in static contexts)
const data = await fetch('https://api.example.com/products', {
cache: 'force-cache',
})
// No-store: always fetch fresh (bypass Next.js data cache)
const liveData = await fetch('https://api.example.com/live-price', {
cache: 'no-store',
})
// ISR-style revalidation: cache for N seconds, then revalidate
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 300 }, // revalidate after 5 minutes
})
// Tag-based invalidation: cache until revalidateTag() is called
const article = await fetch(`https://api.example.com/articles/${id}`, {
next: { tags: [`article-${id}`, 'articles'] },
})
// ── On-demand revalidation: Server Actions ────────────────────────────
// app/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
// Revalidate a specific page path (re-runs layout + page data fetches)
export async function updateArticle(id: string, content: string) {
await db.articles.update({ where: { id }, data: { content } })
revalidatePath(`/articles/${id}`) // re-renders the page on next request
revalidatePath('/articles') // re-renders the list page
}
// Revalidate all fetch() calls with a matching tag
export async function updateProduct(id: string, data: Partial<Product>) {
await db.products.update({ where: { id }, data })
revalidateTag(`product-${id}`) // invalidates: /api/products/[id] cached fetches
revalidateTag('products') // invalidates: /api/products cached fetches
}
// ── On-demand revalidation: Route Handler webhook ─────────────────────
// app/api/revalidate/route.ts
// Called by a CMS webhook when content changes
export async function POST(request: Request) {
const { secret, tag } = await request.json()
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidateTag(tag)
return Response.json({ revalidated: true, tag })
}
// ── Combining Next.js data cache + HTTP cache headers ─────────────────
// app/api/products/route.ts
// The two layers are independent — both can be configured simultaneously.
export const revalidate = 60 // Next.js server cache: revalidate every 60s
export async function GET(request: Request) {
// This fetch() is cached by Next.js data cache (next: { revalidate: 60 })
const products = await fetch('https://internal-api/products', {
next: { revalidate: 60, tags: ['products'] },
}).then(r => r.json())
// The Route Handler response gets its own HTTP cache headers
// for the browser and CDN — independent of the server-side cache
return Response.json(products, {
headers: {
'Cache-Control': 'public, max-age=30, s-maxage=3600, stale-while-revalidate=30',
'ETag': generateETag(products),
},
})
}
// ── Summary: Next.js caching layers ──────────────────────────────────
// Layer 1: Next.js data cache (fetch() next:{} options)
// Scope: Next.js server only
// Control: cache: 'force-cache' | 'no-store', next.revalidate, next.tags
// Invalidation: revalidateTag(), revalidatePath()
//
// Layer 2: HTTP Cache-Control (browser + CDN)
// Scope: browser, CDN, proxies
// Control: Cache-Control, ETag, Last-Modified headers in Response
// Invalidation: CDN purge API, TTL expiry, conditional requests (304)The most common Next.js caching mistake is relying on export const revalidate = N alone and assuming it controls CDN caching — it only controls the Next.js server-side data cache. You must also set Cache-Control headers in the Response to enable CDN caching of your Route Handler output. Use revalidateTag() for on-demand server-cache invalidation and pair it with a CDN purge API call to invalidate both caching layers atomically on data writes. For JSON in Next.js API routes including streaming and edge runtime patterns, see the linked guide.
Key Terms
- ETag
- An HTTP response header (entity tag) containing a fingerprint — typically an MD5 or SHA-1 hash — of the response body. ETags enable conditional GET requests: the client stores the ETag value and sends it in an
If-None-Matchrequest header on subsequent requests. If the resource has not changed, the server returns 304 Not Modified with no body, saving 100% of response body transfer. Strong ETags ("hash") require byte-identical responses; weak ETags (W/"hash") allow semantically equivalent responses (same data, differing whitespace or non-semantic fields) to match. ETags have sub-second precision — they detect changes by content fingerprint, not by timestamp — making them more precise than Last-Modified for frequently updated resources. - Cache-Control
- An HTTP header that specifies caching directives for both requests and responses. Key response directives for JSON APIs:
public(any cache may store),private(browser only, no CDN),no-store(never cache),no-cache(store but always revalidate),max-age=N(cache for N seconds),s-maxage=N(CDN-only TTL, overrides max-age for shared caches),stale-while-revalidate=N(serve stale for N seconds while revalidating in background),stale-if-error=N(serve stale for N seconds when origin returns 5xx). The most impactful combination for high-traffic public JSON APIs is:public, max-age=60, s-maxage=3600, stale-while-revalidate=60— short browser TTL, long CDN TTL, zero-latency revalidation. - Conditional Request
- An HTTP request that includes a validator header (
If-None-MatchorIf-Modified-Since) allowing the server to return 304 Not Modified instead of the full response when the resource has not changed. The flow: on the first request, the server returns 200 with an ETag or Last-Modified header; the client caches the response and stores the validator; on subsequent requests after cache expiry, the client sends the validator; the server compares it to the current resource state and returns 304 (no body) if unchanged, or 200 with new content if changed. Conditional requests save body transfer entirely — only response headers are sent on 304. They work with any Cache-Control TTL includingno-cache(which forces revalidation on every request but still benefits from 304). - 304 Not Modified
- An HTTP status code returned by a server when a conditional GET request determines the resource has not changed since the client's cached version. The 304 response contains no body — only headers. The client uses its locally cached body combined with the fresh headers from the 304 response to reconstruct a complete "200-equivalent" response. For JSON APIs, a 304 saves 100% of response body transfer — a 500KB JSON response that has not changed costs only the header bytes (typically <1KB) to revalidate. CDNs also propagate 304 responses, allowing edge caches to extend their stored entries without re-fetching the body from the origin.
- Surrogate Key (Cache Tag)
- A mechanism for grouping CDN-cached responses by arbitrary labels (tags) so they can be purged as a group with a single API call. Implemented via vendor-specific headers:
CF-Cache-Tag(Cloudflare) orSurrogate-Key(Fastly). A JSON response for/api/products/123might be taggedproduct-123,products,catalog. When product 123 is updated, a single purge-by-tag call removes all cached responses taggedproduct-123— including list endpoints, featured sections, and search results that contained the product — without knowing their exact URLs. Surrogate keys enable long CDN TTLs (hours to days) combined with near-instant invalidation (under 1 second) on data writes. - stale-while-revalidate
- A
Cache-Controlextension directive (RFC 5861) that instructs caches to serve a stale (past max-age) response immediately while simultaneously fetching a fresh copy from the origin in the background. The directive value specifies the window in seconds during which stale responses may be served:Cache-Control: public, max-age=300, stale-while-revalidate=60means the response is fresh for 300 seconds, then stale-acceptable for 60 more seconds, during which every request gets the cached response instantly and one background request refreshes the cache. This eliminates the "cache expiry latency spike" where the first request after max-age expiry blocks waiting for the origin. Supported by Cloudflare, Fastly, Varnish, and modern browsers (Chrome, Firefox, Safari). - Vary Header
- An HTTP response header that lists which request headers must match for a cached response to be reused.
Vary: Accept-Encodingtells caches to store separate entries for gzip-compressed and uncompressed responses — the only safe and recommended Vary value for JSON APIs.Vary: Acceptfragments the CDN cache by every distinct Accept header value.Vary: Authorizationcreates one cache entry per unique Authorization token, making CDN caching effectively useless.Vary: Cookiesimilarly fragments by session. The practical rule: setVary: Accept-Encodingfor compressed JSON responses, and avoid all other Vary values; use URL-based routing orCache-Control: privatefor other dimensions. - Cache Invalidation
- The process of removing or marking as stale a cached JSON response when the underlying data changes, so subsequent requests receive fresh data. The four main strategies for HTTP-cached JSON APIs: (1) TTL expiry — let the cache self-heal when max-age passes; (2) stale-while-revalidate — TTL-based with zero-latency background revalidation; (3) surrogate key purge — active CDN purge by tag on every write, enabling long TTLs with near-instant propagation; (4) event-driven purge — a message queue or database trigger fires a CDN purge API call on data changes, decoupling invalidation from the write path. The gold standard for production JSON APIs combines surrogate key purge (for immediate CDN invalidation) with stale-while-revalidate (as a safety net for missed purge events).
FAQ
What Cache-Control headers should a JSON API set for public vs authenticated responses?
For public JSON API responses (no user-specific data): Cache-Control: public, max-age=300, stale-while-revalidate=60 — allows CDNs and browsers to cache for 5 minutes, then serve stale for 60 more seconds while revalidating in the background. Add s-maxage=3600 to give CDN edges a separate 1-hour TTL: Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=60. For authenticated JSON tied to a specific user: Cache-Control: private, max-age=60 — only the browser caches; the private directive prevents any CDN or shared proxy from storing the response. For fully real-time data: Cache-Control: no-store. For data that must revalidate but can use ETags: Cache-Control: no-cache. Always add Vary: Accept-Encoding when serving gzip-compressed JSON. Never set Vary: Authorization — use private instead.
How do I implement ETag caching for a JSON API in Express or Next.js?
In Express, enable automatic ETags with app.set('etag', 'strong') — Express computes a SHA-1 hash of the response body, sets the ETag header, and returns 304 Not Modified when the client's If-None-Match matches. For manual control: compute createHash('sha1').update(JSON.stringify(data)).digest('hex'), set res.setHeader('ETag', '"' + hash + '"'), then check if (req.headers['if-none-match'] === etag) { res.sendStatus(304); return; }. In Next.js Route Handlers, check request.headers.get('if-none-match') and return new Response(null, { status: 304 }) on match; otherwise include the ETag in your Response headers. Generate ETags from sorted JSON keys for deterministic hashes — JSON.stringify() key order depends on object insertion order in V8, which can produce different strings for identical data from different queries.
What is the difference between ETag and Last-Modified for JSON API caching?
Both are conditional request mechanisms that enable 304 Not Modified responses, but they differ in precision. Last-Modified is an HTTP date header (1-second resolution) — two writes within the same second cannot be distinguished. ETag is a content fingerprint (MD5 or SHA-1 hash) — changes are detected by content regardless of timing, with sub-second precision. Use ETag when the resource can change multiple times per second, when you have a computed JSON response without a single updated_at timestamp, or when you need strong validation for range requests. Use Last-Modified when the resource maps to a database record with a reliable updated_at column and the simpler implementation is preferred. Best practice: set both headers — ETags take precedence for HTTP/1.1+ clients (RFC 7232), and Last-Modified serves as fallback for older clients and some CDN configurations. Express's built-in etag: strong handles ETags automatically; Last-Modified requires manual implementation.
How do I configure a CDN (Cloudflare, Fastly) to cache JSON API responses?
Cloudflare does not cache API responses by default — create a Cache Rule (Rules > Cache Rules) matching your API path pattern (e.g., /api/*) and set Cache Status to "Cache Everything". Then set Cache-Control: public, s-maxage=3600 from your origin — Cloudflare uses s-maxage for its edge TTL. Add CF-Cache-Tag: resource-id,resource-type response headers for surrogate key invalidation, then call the Cloudflare purge-by-tag API on data writes. For Fastly: set Surrogate-Control: max-age=3600 for the CDN TTL (Fastly strips this before forwarding to browsers) and Surrogate-Key: resource-id resource-type for cache tags. For both CDNs: ensure your origin sets Cache-Control: public — the private directive prevents CDN caching entirely. Set Vary: Accept-Encoding to ensure the CDN stores separate entries for compressed and uncompressed responses. Verify CDN caching with curl -I — check CF-Cache-Status: HIT (Cloudflare) or X-Cache: HIT (Fastly).
What is stale-while-revalidate and how does it improve JSON API performance?
stale-while-revalidate is a Cache-Control extension (RFC 5861) that allows a cache to serve a stale (expired) response instantly while fetching a fresh copy from the origin in the background. Cache-Control: public, max-age=300, stale-while-revalidate=60 means: for the first 300 seconds the response is fresh; from 300 to 360 seconds, any cache hit returns the stale response immediately and triggers a background revalidation request; after 360 seconds, the next request blocks synchronously on the origin. The benefit: zero perceived latency for cache revalidation. Without it, the first request after max-age expiry on a popular endpoint blocks — users see a 100ms+ latency spike while the origin responds. With it, every user gets a cached response. Cloudflare, Fastly, Varnish, and modern browsers all support this directive. Combine with stale-if-error=86400 to serve stale JSON when the origin is down.
How do I invalidate cached JSON API responses across a CDN?
There are four main strategies. Purge by URL: call the CDN purge API with the exact URL on each resource update — simple but does not handle list/aggregation endpoints that contain the changed resource. Surrogate key (cache tag) purge: tag JSON responses with resource identifiers using CF-Cache-Tag (Cloudflare) or Surrogate-Key (Fastly) headers, then purge all responses for a tag in a single API call when data changes — this is the recommended approach. Event-driven invalidation: use a database trigger (Postgres LISTEN/NOTIFY) or message queue to fire a CDN purge API call asynchronously on every write, decoupling invalidation from the write path. stale-while-revalidate: use short max-age values (30–60 seconds) so caches self-heal without active purging — simplest but data can be stale for up to max-age seconds. For Next.js, call revalidateTag() in a Server Action to invalidate the server-side data cache, and make a CDN purge API call in the same function to invalidate the CDN layer simultaneously.
How does the Vary header affect JSON API caching in browsers and CDNs?
The Vary header lists request headers that must match for a cached response to be reused — each unique combination of Vary header values creates a separate cache entry. Vary: Accept-Encoding is safe and necessary: it creates two cache entries per endpoint (one for gzip clients, one for uncompressed) and almost all clients send consistent Accept-Encoding values, so hit rates remain high. Vary: Accept is risky: Axios sends application/json, text/plain, */*, browsers send */* or more specific values — this fragments the CDN cache into multiple entries per endpoint with low hit rates. Vary: Authorization is harmful: one unique cache entry per user token, making CDN caching effectively useless — use Cache-Control: private instead. Vary: Cookie has the same problem. Vary: Accept-Language fragments by locale — use URL-based routing (/api/en/products) instead. The practical rule: only set Vary: Accept-Encoding for JSON APIs.
Further reading and primary sources
- RFC 9111: HTTP Caching — The definitive HTTP/1.1 caching specification — Cache-Control directives, freshness model, and validation
- RFC 5861: stale-while-revalidate and stale-if-error — Specification for the stale-while-revalidate and stale-if-error Cache-Control extensions
- RFC 7232: Conditional Requests (ETag, Last-Modified) — ETag, If-None-Match, Last-Modified, and If-Modified-Since — the conditional request protocol
- Cloudflare Cache Rules Documentation — How to configure Cloudflare to cache JSON API responses, set custom TTLs, and use cache tags
- Fastly Surrogate Keys Guide — Fastly surrogate key (cache tag) invalidation for JSON APIs — bulk purge by resource identifier
- Next.js Caching Documentation — Next.js App Router data cache, fetch() options, revalidateTag, revalidatePath, and Route Handler caching