JSON Caching Strategies: HTTP Headers, Redis, CDN, and Cache Invalidation

Last updated:

Effective caching is the single highest-leverage performance improvement for JSON APIs. A well-cached API response serves in under 5ms instead of 200ms, handles 100× more traffic, and reduces database load dramatically. This guide covers the full stack: HTTP cache headers, Redis application caching, CDN edge caching, and cache invalidation strategies.

HTTP Cache-Control Headers for JSON APIs

// Express.js — Cache-Control header patterns

// Public JSON data (product catalog, public profiles)
res.set({
  'Cache-Control': 'public, max-age=300, stale-while-revalidate=60',
  'Content-Type':  'application/json',
  'Vary':          'Accept-Encoding',
})

// User-specific data (dashboard, account info)
res.set({
  'Cache-Control': 'private, max-age=60',
  'Content-Type':  'application/json',
})

// Real-time data (stock prices, live inventory)
res.set({
  'Cache-Control': 'no-store',
  'Content-Type':  'application/json',
})

// Frequently checked but rarely changed (config, feature flags)
res.set({
  'Cache-Control': 'public, max-age=0, must-revalidate',
  'ETag':          etag,           // hash of response body
  'Last-Modified': lastModified,   // ISO 8601 string
  'Content-Type':  'application/json',
})

ETag Implementation

ETag enables conditional GET: the client sends the cached ETag; the server returns 304 Not Modified (no body) if unchanged.

import crypto from 'crypto'

function generateETag(data: unknown): string {
  const hash = crypto
    .createHash('md5')
    .update(JSON.stringify(data))
    .digest('hex')
  return '"' + hash + '"'  // ETags must be quoted strings
}

// Express middleware
app.get('/api/products', async (req, res) => {
  const products = await db.getProducts()
  const etag = generateETag(products)

  // Check conditional request
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end()  // Not Modified — no body sent
  }

  res
    .set('ETag', etag)
    .set('Cache-Control', 'public, max-age=0, must-revalidate')
    .json(products)
})

Redis Application Cache

import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)

const CACHE_TTL = 300  // 5 minutes

// Cache-aside (lazy loading) pattern
async function getUser(userId: string) {
  const key = `user:${userId}`

  // 1. Check cache
  const cached = await redis.get(key)
  if (cached) {
    return JSON.parse(cached)  // cache hit
  }

  // 2. Cache miss — fetch from DB
  const user = await db.findUser(userId)
  if (!user) return null

  // 3. Store in cache with TTL
  await redis.setex(key, CACHE_TTL, JSON.stringify(user))

  return user
}

// Invalidate on update
async function updateUser(userId: string, data: Partial<User>) {
  const updated = await db.updateUser(userId, data)
  await redis.del(`user:${userId}`)  // bust the cache
  return updated
}

// Cache list queries with composite key
async function getProductsByCategory(categoryId: string, page: number) {
  const key = `products:cat:${categoryId}:page:${page}`
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached)

  const products = await db.getProducts({ categoryId, page })
  await redis.setex(key, 60, JSON.stringify(products))  // shorter TTL for lists
  return products
}

Cache Key Design

PatternKey exampleUse when
Entity + IDuser:123Single resource GET
Query fingerprintproducts:cat:5:page:2Filtered/paginated lists
URL hashcache:md5(path+query)Arbitrary API responses
Tenant-scopedtenant:acme:user:123Multi-tenant apps
Versionedv2:products:123Schema/API versioning

CDN Caching for JSON APIs

// Set s-maxage for CDN-specific TTL (overrides max-age for CDNs)
res.set({
  'Cache-Control': 'public, max-age=60, s-maxage=300',
  // CDNs cache for 300s; browsers cache for 60s
})

// Cloudflare: add Cache-Tag for surrogate-key invalidation
res.set({
  'Cache-Control': 'public, max-age=300',
  'Cache-Tag':     'products product:123 category:electronics',
})

// Purge by tag via Cloudflare API when product 123 changes:
// POST https://api.cloudflare.com/client/v4/zones/{zoneId}/cache/tags
// { "tags": ["product:123"] }

// Fastly: Surrogate-Key header for tag-based purging
res.set({
  'Surrogate-Key':     'products product-123',
  'Surrogate-Control': 'max-age=300',
})

Cache Invalidation Patterns

// 1. Event-driven invalidation (immediate consistency)
async function updateProduct(id: string, data: Partial<Product>) {
  const updated = await db.updateProduct(id, data)

  // Delete all affected cache entries
  await redis.del(`product:${id}`)
  await redis.del(`products:featured`)  // featured list may include this product

  // Publish event for other services to invalidate their caches
  await pubsub.publish('product.updated', { id, ...data })

  return updated
}

// 2. Versioned keys — no explicit deletion needed
let productVersion = await redis.get('products:version') ?? '1'

async function getProductsV(version: string) {
  const key = `products:v:${version}`
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached)
  const products = await db.getProducts()
  await redis.setex(key, 3600, JSON.stringify(products))
  return products
}

async function invalidateProductCache() {
  const newVersion = Date.now().toString()
  await redis.set('products:version', newVersion)
  // Old versioned keys expire naturally after 1 hour
}

// 3. Write-through cache (always consistent)
async function setUser(userId: string, user: User) {
  const [dbResult] = await Promise.all([
    db.saveUser(userId, user),
    redis.setex(`user:${userId}`, 300, JSON.stringify(user)),
  ])
  return dbResult
}

stale-while-revalidate Pattern

// Server: set stale-while-revalidate for background refresh
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
// Clients/CDNs serve stale (up to 5 min old) while fetching fresh in background

// Application-level SWR with Redis
async function getWithSWR(key: string, fetchFn: () => Promise<unknown>, ttl: number) {
  const cached = await redis.get(key)

  if (cached) {
    const { data, expiresAt } = JSON.parse(cached)
    const isStale = Date.now() > expiresAt

    if (isStale) {
      // Return stale immediately, refresh in background
      fetchFn()
        .then(fresh => redis.setex(key, ttl, JSON.stringify({
          data: fresh,
          expiresAt: Date.now() + ttl * 1000,
        })))
        .catch(console.error)
    }

    return data  // always return cached data immediately
  }

  // No cache — must wait for fresh data
  const fresh = await fetchFn()
  await redis.setex(key, ttl, JSON.stringify({
    data: fresh,
    expiresAt: Date.now() + ttl * 1000,
  }))
  return fresh
}

Caching Decision Guide

Data typeHTTP Cache-ControlRedis TTLCDN
Static config/enumspublic, max-age=864001 hour+Yes, aggressive
Product catalogpublic, max-age=300, swr=605 minYes + purge on change
User profileprivate, max-age=605 minNo
User-specific listsprivate, no-store2–5 minNo
Search resultsprivate, max-age=301 minNo
Financial / paymentsno-storeNoneNever

FAQ

What HTTP headers should I use to cache JSON API responses?

For public APIs: Cache-Control: public, max-age=300, stale-while-revalidate=60. For private/user-specific: Cache-Control: private, max-age=60. For real-time: Cache-Control: no-store. Always set Content-Type: application/json and include ETag for resources that support conditional requests. The Vary: Accept-Encoding header prevents serving the wrong (compressed/uncompressed) variant from cache when you support gzip.

How does ETag work for JSON API caching?

The server sends ETag: "abc123" (a hash of the response body) with each response. The client caches the response and ETag. On the next request, the client sends If-None-Match: "abc123". If the resource is unchanged, the server returns 304 Not Modified with no body — saving bandwidth. If changed, the server returns 200 with the new body and a new ETag. Generate ETags by hashing JSON.stringify(data) with MD5 or SHA-256. ETags are most valuable when data changes unpredictably but not constantly — they save bandwidth without requiring you to predict TTL values.

How do I cache JSON responses in Redis?

The cache-aside pattern: check Redis first (GET key), on miss fetch from database, store the result (SETEX key ttl value), return the data. Use JSON.stringify before storing and JSON.parse after retrieving. Design cache keys to reflect query scope: entity:id for single resources, entity:filter1:filter2:page for parameterized lists. Invalidate by deleting relevant keys on write. Keep TTLs short for frequently mutated data, longer for reference data.

What is stale-while-revalidate and when should I use it?

stale-while-revalidate allows serving a cached response past its max-age while asynchronously fetching a fresh copy. For example, max-age=60, stale-while-revalidate=300 means serve cached data for up to 6 minutes (1 + 5) without blocking, as long as a background revalidation is triggered after the first minute. Use it for content where a few extra minutes of staleness is acceptable in exchange for always-fast responses: news feeds, product listings, public profiles. Avoid it for inventory counts, pricing, and financial data where staleness has business consequences.

How do I implement cache invalidation for JSON API responses?

The four main strategies: (1) TTL expiry — simple, eventual consistency; (2) event-driven delete — on write, immediately delete affected cache keys; (3) surrogate keys / cache tags — tag entries with entity IDs, purge all entries with a tag via CDN API on change; (4) versioned keys — embed a version number in the key, increment on change, old entries expire naturally. Event-driven invalidation is the most consistent; versioned keys are the simplest for CDN use cases. Don't try to build a perfect cache — design for "eventually consistent within N seconds" rather than "always fresh".

Should I cache JSON API responses at the CDN level?

Yes, for public JSON APIs with any tolerance for staleness. CDN caching reduces latency from ~200ms (origin) to ~5ms (edge), absorbs traffic spikes, and reduces origin server load. Set s-maxage to the CDN-specific TTL. Use CDN surrogate keys (Cloudflare Cache-Tag, Fastly Surrogate-Key) to invalidate specific resources without full cache purges. Never CDN-cache private or authenticated responses — use Vary: Authorization at minimum, but this effectively disables CDN caching; use Redis instead for user-specific data.

What is the difference between no-cache and no-store in HTTP?

no-store: never write the response to any persistent storage (disk, memory, CDN). Every request goes to the origin. Use for payment data, session tokens, medical records. no-cache: store the response, but revalidate with the origin before each use — always sends a conditional request (If-None-Match or If-Modified-Since). If the server returns 304, serve the cached version; if 200, update the cache. no-cache with ETag is bandwidth-efficient for data that changes rarely but at unpredictable times.

How do I cache JSON in the browser with the Cache API?

Use Service Workers with the Cache API for offline-capable JSON caching. The cache-first strategy: open a named cache, check for a matching request, return the cached response if found, otherwise fetch from network and cache the response. For JSON APIs, the network-first strategy is usually safer: try the network, fall back to cache on failure. The Cache API stores full Response objects — call response.json() to access the data. Combine with Cache-Control headers: the HTTP cache and Service Worker Cache are separate layers that coexist.

Further reading and primary sources