JSON Caching Strategies: Redis, ETags, stale-while-revalidate & Next.js
Last updated:
Caching JSON API responses reduces latency from 200 ms (database round-trip) to under 1 ms (in-memory cache) for identical requests — the most impactful single optimization for read-heavy JSON APIs. Cache-aside (lazy loading) is the most common pattern — check cache first, fetch from database on miss, write to cache with a TTL of 60–3600 seconds. Write-through keeps cache and database in sync by writing to both simultaneously but doubles write latency.
This guide covers HTTP cache headers (ETag, Cache-Control, Last-Modified), Redis JSON caching with RedisJSON, cache invalidation strategies, stale-while-revalidate, and Next.js fetch cache with revalidate. Every pattern includes cache key design and TTL recommendations. For raw serialization performance, see the companion guide on JSON performance.
HTTP Cache Headers for JSON APIs: ETag, Cache-Control, and Last-Modified
HTTP caching is the outermost and cheapest caching layer — the browser and CDN serve cached JSON without a single byte reaching your origin server. Cache-Control, ETag, and Last-Modified are the three response headers that control this behavior. Getting them right eliminates redundant origin requests before any application-level caching is needed.
import crypto from 'node:crypto'
// ── Cache-Control directives for JSON APIs ────────────────────────
// Public JSON (product catalog, public feed) — browser + CDN cache
res.set({
'Cache-Control': 'public, max-age=300, stale-while-revalidate=60',
'Content-Type': 'application/json; charset=utf-8',
'Vary': 'Accept-Encoding',
})
// CDN-specific TTL (s-maxage) different from browser TTL (max-age)
res.set({
'Cache-Control': 'public, max-age=60, s-maxage=300',
// CDNs cache for 5 min; browsers cache for 1 min
})
// Private JSON (user dashboard, account info) — browser only, no CDN
res.set({ 'Cache-Control': 'private, max-age=60' })
// Real-time JSON (live prices, streaming inventory) — no cache
res.set({ 'Cache-Control': 'no-store' })
// ── ETag: conditional GET to skip re-downloading unchanged JSON ───
function etag(data: unknown): string {
return '"' + crypto
.createHash('md5')
.update(JSON.stringify(data))
.digest('hex') + '"'
}
app.get('/api/products', async (req, res) => {
const products = await db.getProducts()
const tag = etag(products)
// 304 Not Modified — no body sent if ETag matches
if (req.headers['if-none-match'] === tag) {
return res.status(304).end()
}
res
.set('ETag', tag)
.set('Cache-Control', 'public, max-age=0, must-revalidate')
.json(products)
})
// ── Last-Modified: date-based conditional GET ─────────────────────
app.get('/api/config', async (req, res) => {
const { data, updatedAt } = await db.getConfig()
const lastModified = updatedAt.toUTCString()
if (req.headers['if-modified-since'] === lastModified) {
return res.status(304).end()
}
res
.set('Last-Modified', lastModified)
.set('Cache-Control', 'public, max-age=60')
.json(data)
})Use public only for responses that are identical for all users — never on responses containing session tokens, user-specific data, or authorization-gated content. Combine ETag with max-age=0, must-revalidate for data that changes at unpredictable intervals: the browser always validates, but 304 responses avoid re-downloading the body. For data on a predictable refresh schedule, a longer max-age with stale-while-revalidate is simpler and requires no server-side hash computation per request. See also our guide on JSON API design for structuring cache-friendly response shapes.
Cache-Aside Pattern: Lazy JSON Caching with Redis
Cache-aside (also called lazy loading) is the most common Redis caching pattern for JSON APIs — check the cache before querying the database, and populate the cache only on a miss. The application owns all cache logic: it decides what to cache, what TTL to use, and when to invalidate. This pattern adds at most one extra round-trip (the cache miss) on cold requests, and zero extra round-trips on hits.
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
// ── Basic cache-aside ─────────────────────────────────────────────
async function getUser(userId: string) {
const key = `user:${userId}`
// 1. Check cache
const cached = await redis.get(key)
if (cached !== null) return JSON.parse(cached) // cache HIT
// 2. Cache MISS — fetch from database
const user = await db.findUser(userId)
if (!user) return null
// 3. Write to cache with 5-minute TTL
await redis.setex(key, 300, JSON.stringify(user))
return user
}
// ── Cache key design conventions ─────────────────────────────────
// Single entity: "user:123"
// Parameterized: "products:category:electronics:page:2"
// Multi-tenant: "tenant:acme:user:123"
// Aggregates: "stats:daily:2025-12-28"
// ── Cache stampede prevention: mutex lock ─────────────────────────
// Multiple concurrent misses on the same key all hit the DB → stampede
async function getUserSafe(userId: string) {
const key = `user:${userId}`
const lockKey = `lock:${key}`
const cached = await redis.get(key)
if (cached !== null) return JSON.parse(cached)
// Only one process acquires the lock (NX = set if Not eXists)
const acquired = await redis.set(lockKey, '1', 'EX', 5, 'NX')
if (!acquired) {
// Another process is fetching — wait 100ms and retry from cache
await new Promise(r => setTimeout(r, 100))
return getUserSafe(userId)
}
try {
const user = await db.findUser(userId)
await redis.setex(key, 300, JSON.stringify(user))
return user
} finally {
await redis.del(lockKey) // always release lock
}
}
// ── Cache invalidation on write ───────────────────────────────────
async function updateUser(userId: string, data: Partial<User>) {
const updated = await db.updateUser(userId, data)
await redis.del(`user:${userId}`) // bust the stale cache entry
return updated
}
// ── Multi-key fetch: pipeline for batch reads ─────────────────────
async function getUsers(userIds: string[]) {
const keys = userIds.map(id => `user:${id}`)
const results = await redis.mget(...keys)
const misses: string[] = []
const users: User[] = results.map((r, i) => {
if (r !== null) return JSON.parse(r)
misses.push(userIds[i])
return null
}) as User[]
if (misses.length > 0) {
const fetched = await db.findUsers(misses)
const pipeline = redis.pipeline()
fetched.forEach(u => {
pipeline.setex(`user:${u.id}`, 300, JSON.stringify(u))
users[userIds.indexOf(u.id)] = u
})
await pipeline.exec()
}
return users
}Use SETEX (or SET key value EX seconds) rather than SET followed by EXPIRE — the two-command approach has a race window where a crash between the commands leaves a key with no TTL, causing it to persist indefinitely. Cache stampede is the most common production failure mode for cache-aside: when a popular key expires under high load, hundreds of concurrent requests all miss and hit the database simultaneously. The mutex lock pattern above prevents stampede by allowing only one request to fetch while others wait and retry from cache. For very high concurrency, probabilistic early expiry (pre-expiring 5–10% of requests before TTL) is more scalable than locking.
Write-Through and Write-Behind Caching for JSON
Write-through caching updates both the cache and the database on every write, keeping them permanently in sync. Unlike cache-aside, there are no cold-start misses — the cache is always populated. Write-behind (write-back) goes further: it writes to the cache immediately and persists to the database asynchronously, reducing write latency at the cost of potential data loss on crash.
// ── Write-through: cache + DB updated atomically ─────────────────
async function saveUser(userId: string, user: User): Promise<User> {
// Write to database first — source of truth
const saved = await db.saveUser(userId, user)
// Immediately update cache (no stale window)
await redis.setex(`user:${userId}`, 300, JSON.stringify(saved))
return saved
}
// ── Write-through with Redis transaction (MULTI/EXEC) ─────────────
// If you need to set multiple related keys atomically in Redis
async function saveUserWithRelated(user: User) {
const saved = await db.saveUser(user.id, user)
const pipeline = redis.pipeline()
pipeline.setex(`user:${user.id}`, 300, JSON.stringify(saved))
pipeline.setex(`user:email:${user.email}`, 300, user.id) // index by email
pipeline.del(`user:search:*`) // bust search cache
await pipeline.exec()
return saved
}
// ── Write-behind: async DB write, immediate cache update ──────────
// WARNING: risk of data loss if the worker crashes before DB write
const writeQueue: Array<{ userId: string; user: User }> = []
async function saveUserBehind(userId: string, user: User) {
// Write to cache immediately — reads see fresh data at once
await redis.setex(`user:${userId}`, 300, JSON.stringify(user))
// Enqueue DB write (process in background worker)
writeQueue.push({ userId, user })
return user
}
// Background worker drains the queue
setInterval(async () => {
const batch = writeQueue.splice(0, 100) // take up to 100 items
if (batch.length === 0) return
await db.bulkSaveUsers(batch) // batched DB write
}, 500)
// ── When to use each pattern ──────────────────────────────────────
// cache-aside: Read-heavy, writes infrequent, staleness acceptable
// write-through: Strong consistency, reads + writes both frequent
// write-behind: Write-heavy, eventual consistency acceptable, low DB load requiredWrite-through doubles write latency — both the database write and the Redis write must complete before returning to the caller. On AWS, a database write averages 5–20 ms and a Redis write under 0.5 ms, so the overhead is usually under 5%. Write-behind eliminates this overhead but introduces a durability risk: if the application crashes before the background worker flushes the queue, writes in the queue are lost. Use write-behind only for non-critical data (analytics events, view counts, activity logs) where occasional loss is acceptable. For financial transactions, user records, or any data where loss is unacceptable, use write-through or cache-aside with event-driven invalidation. See our guide on JSON security for protecting cached responses against injection and cache poisoning.
RedisJSON: Storing and Querying JSON Natively in Redis
RedisJSON (part of Redis Stack) adds JSON as a native data type in Redis, enabling atomic partial reads and writes via JSONPath without client-side serialize/deserialize cycles. The primary advantage over string-based caching is partial field access: JSON.GET key $.user.name fetches only the name field from a 10KB document without loading or deserializing the rest. This is most impactful for large documents with many fields where reads consistently access only a subset.
import { createClient } from 'redis'
const client = await createClient({ url: process.env.REDIS_URL })
.on('error', err => console.error('Redis error', err))
.connect()
// ── JSON.SET: store a JSON document natively ──────────────────────
await client.json.set('product:42', '$', {
id: 42,
name: 'Widget Pro',
price: 29.99,
stock: 150,
tags: ['electronics', 'accessories'],
specs: { weight: '120g', color: 'black' },
})
// Set TTL separately (RedisJSON does not accept EX in JSON.SET)
await client.expire('product:42', 300)
// ── JSON.GET: fetch full document or sub-path ─────────────────────
const full = await client.json.get('product:42') // entire document
const price = await client.json.get('product:42', { path: '$.price' }) // [29.99]
const name = await client.json.get('product:42', { path: '$.name' }) // ["Widget Pro"]
const color = await client.json.get('product:42', { path: '$.specs.color' }) // ["black"]
// ── JSON.MGET: fetch multiple documents in one round-trip ─────────
const products = await client.json.mGet(
['product:42', 'product:43', 'product:44'],
'$'
)
// Returns [doc42, doc43, doc44] — null for missing keys
// ── Atomic partial updates — no read-modify-write needed ──────────
// Decrement stock atomically
await client.json.numIncrBy('product:42', '$.stock', -1)
// Append to a JSON array
await client.json.arrAppend('product:42', '$.tags', 'sale')
// Update a nested field
await client.json.set('product:42', '$.price', 24.99)
// ── String-based approach for comparison ─────────────────────────
// Traditional: must load + deserialize full document to update one field
const raw = await redis.get('product:42')
const doc = JSON.parse(raw)
doc.stock -= 1
await redis.setex('product:42', 300, JSON.stringify(doc))
// Race condition risk: another request may have decremented stock between GET and SETThe atomic partial update commands (JSON.NUMINCRBY, JSON.ARRAPPEND, JSON.SET with a path) eliminate the read-modify-write cycle that plagues string-based caching — no race condition between GET and SET. For inventory decrement or counter increment, JSON.NUMINCRBY is safer than GET + parse + modify + serialize + SET. RedisJSON is available in Redis Cloud (all tiers) and self-hosted Redis Stack (install with docker run redis/redis-stack). For small JSON objects under 500 bytes where you always read the full document, the string-based SETEX/GET + JSON.stringify/parse approach is simpler and the performance difference is negligible.
Cache Invalidation: TTL, Event-Based, and Tag-Based Strategies
Cache invalidation is the hardest problem in caching — Phil Karlton famously called it one of only two hard problems in computer science. The goal is to serve fresh enough data without sacrificing the latency and load-reduction benefits of caching. Four strategies address different trade-offs between consistency, complexity, and operational overhead.
// ── Strategy 1: TTL expiry (simplest, eventual consistency) ──────
// Let keys expire naturally — stale data served until TTL elapses
await redis.setex('products:featured', 60, JSON.stringify(products))
// Stale for up to 60s after a product update — acceptable for catalogs
// ── Strategy 2: Event-driven delete (immediate consistency) ───────
async function updateProduct(id: string, data: Partial<Product>) {
const updated = await db.updateProduct(id, data)
// Bust all related cache entries atomically via pipeline
const pipe = redis.pipeline()
pipe.del(`product:${id}`) // single entity
pipe.del('products:featured') // list cache
pipe.del('products:homepage') // aggregate cache
await pipe.exec()
// Publish event so other services invalidate their caches too
await pubsub.publish('product.updated', JSON.stringify({ id }))
return updated
}
// ── Strategy 3: Tag-based invalidation ───────────────────────────
// Track which cache keys belong to each entity using a Redis Set
async function cacheWithTag(key: string, data: unknown, tag: string, ttl = 300) {
await redis.pipeline()
.setex(key, ttl, JSON.stringify(data))
.sadd(`tag:${tag}`, key) // record key under the tag
.expire(`tag:${tag}`, ttl + 60) // tag Set lives slightly longer
.exec()
}
async function invalidateTag(tag: string) {
const keys = await redis.smembers(`tag:${tag}`)
if (keys.length > 0) {
await redis.del(...keys) // delete all tagged keys
}
await redis.del(`tag:${tag}`) // clean up the tag Set
}
// Usage
await cacheWithTag('user:123:profile', profile, 'user:123')
await cacheWithTag('user:123:orders', orders, 'user:123')
await invalidateTag('user:123') // busts both keys at once
// ── Strategy 4: Versioned keys (no explicit deletion needed) ──────
async function getVersionedProducts() {
const version = await redis.get('products:version') ?? '1'
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 invalidateProducts() {
// Bump version — all reads immediately miss to the new key
await redis.set('products:version', Date.now().toString())
// Old versioned keys expire naturally after 1 hour — no cleanup needed
}
// ── CDN invalidation: Cloudflare Cache-Tag ────────────────────────
res.set('Cache-Tag', `product product:${id} category:${category}`)
// On update, call Cloudflare API to purge by tag:
// POST /zones/{zoneId}/cache/tags { "tags": ["product:42"] }Tag-based invalidation scales to complex cache graphs where one entity update should bust multiple dependent caches — user profile, activity feed, notification count — without enumerating each key explicitly in the write path. The downside is that the tag Set itself must be maintained and can drift if keys expire before the tag Set is cleaned up. Versioned keys are the cleanest CDN story: bump a version number in Redis, and all CDN requests to the API include the version in the URL or as a query parameter, so the CDN immediately misses to the new version without a purge API call. For JSON compression combined with caching, compress the JSON string before storing in Redis to reduce memory by 60–80% for text-heavy payloads.
stale-while-revalidate for Zero-Latency JSON Responses
stale-while-revalidate is the single highest-impact caching directive for read-heavy JSON APIs — it serves cached JSON instantly even after TTL expiry, while refreshing the cache asynchronously in the background. The client sees zero added latency on repeated requests, even as the cache refreshes. This eliminates the latency spike that occurs when a high-traffic endpoint expires and all concurrent requests block on origin.
// ── HTTP Cache-Control with stale-while-revalidate ───────────────
// Fresh 60s → stale-but-served 300s (background refresh) → wait for origin
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
// ── Redis: manual stale-while-revalidate ──────────────────────────
// Useful when you need the same semantics at the application layer
interface CacheEntry<T> {
data: T
expiresAt: number // soft TTL — when to start background refresh
hardExpiresAt: number // hard TTL — when to wait for origin (fallback)
}
async function getWithSWR<T>(
key: string,
fetch: () => Promise<T>,
softTtl = 60,
hardTtl = 360,
): Promise<T> {
const raw = await redis.get(key)
if (raw !== null) {
const entry: CacheEntry<T> = JSON.parse(raw)
const now = Date.now()
if (now < entry.expiresAt) {
return entry.data // fresh — return immediately
}
if (now < entry.hardExpiresAt) {
// Stale window — serve stale data, refresh in background
fetch()
.then(fresh => {
const updated: CacheEntry<T> = {
data: fresh,
expiresAt: Date.now() + softTtl * 1000,
hardExpiresAt: Date.now() + hardTtl * 1000,
}
redis.set(key, JSON.stringify(updated), 'EX', hardTtl)
})
.catch(err => console.error('SWR refresh failed', err))
return entry.data // return stale immediately
}
}
// Cache miss or hard expiry — wait for fresh data
const fresh = await fetch()
const entry: CacheEntry<T> = {
data: fresh,
expiresAt: Date.now() + softTtl * 1000,
hardExpiresAt: Date.now() + hardTtl * 1000,
}
await redis.set(key, JSON.stringify(entry), 'EX', hardTtl)
return fresh
}
// Usage — products API with 60s soft TTL, 6 min hard TTL
const products = await getWithSWR('products:featured', () => db.getFeatured(), 60, 360)
// ── Next.js: same semantics via revalidate ────────────────────────
// Next.js fetch cache implements SWR natively — no extra code needed
const data = await fetch('/api/products', { next: { revalidate: 60 } })
// Serves cached JSON, revalidates in background after 60sThe manual Redis implementation above uses two TTLs: a soft TTL (when background refresh starts) and a hard TTL (when to fall back to blocking origin fetch). This prevents indefinitely stale data if the background refresh keeps failing — after the hard TTL elapses, the next request blocks and waits for a fresh response. Use stale-while-revalidate for public product catalogs, news feeds, public user profiles, and any endpoint where 1–5 minutes of staleness has no business consequence. Avoid it for inventory counts, real-time pricing, and any data where a stale value could trigger an incorrect action (e.g., overselling stock).
Next.js fetch Cache and React Server Component Caching
Next.js App Router extends the native fetch() API with caching at the framework level — responses are cached in the Next.js data cache (edge or server-side), not the browser, and shared across all server-side renders. This means a fetch call in a React Server Component is automatically deduplicated within a single render pass and cached across renders according to the revalidate option. No Redis setup is required for basic ISR-style JSON caching.
// ── Next.js App Router: fetch cache options ───────────────────────
// 1. Time-based ISR revalidation — most common for JSON APIs
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }, // revalidate every 60 seconds
}).then(r => r.json())
// 2. Permanent cache — until rebuild or on-demand revalidation
const config = await fetch('https://api.example.com/config', {
cache: 'force-cache',
}).then(r => r.json())
// 3. No cache — real-time data
const stock = await fetch('https://api.example.com/stock', {
cache: 'no-store',
}).then(r => r.json())
// ── unstable_cache: for non-fetch data sources ────────────────────
import { unstable_cache } from 'next/cache'
// Cache a DB query with 5-minute revalidation and a tag for purging
const getCachedProducts = unstable_cache(
async (category: string) => db.getProducts({ category }),
['products'], // cache key namespace
{ revalidate: 300, tags: ['products'] } // TTL + invalidation tag
)
// Use in a Server Component
export default async function ProductsPage() {
const products = await getCachedProducts('electronics')
return <ProductList products={products} />
}
// ── On-demand revalidation: purge cache after mutation ────────────
import { revalidateTag, revalidatePath } from 'next/cache'
// Server Action — triggered by form submit or API call
async function updateProduct(id: string, data: FormData) {
'use server'
await db.updateProduct(id, Object.fromEntries(data))
revalidateTag('products') // purge all cached with 'products' tag
revalidatePath('/products') // or purge a specific page
revalidatePath(`/products/${id}`) // and the individual product page
}
// ── Route Handler: set Cache-Control for edge/CDN caching ────────
// app/api/products/route.ts
export async function GET() {
const products = await db.getProducts()
return Response.json(products, {
headers: {
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
},
})
}
// ── fetch deduplication within a render ──────────────────────────
// Both components call the same URL — Next.js deduplicates to one fetch
async function ProductCard() {
const p = await fetch('/api/product/42').then(r => r.json())
return <div>{p.name}</div>
}
async function ProductPrice() {
const p = await fetch('/api/product/42').then(r => r.json()) // same URL
return <span>{p.price}</span> // one network call
}The Next.js data cache persists across requests — unlike React's request memoization which only deduplicates within a single render pass. On Vercel, the data cache is stored at the edge and revalidations propagate globally within seconds. On self-hosted deployments, the cache is stored on the filesystem of the Next.js server process — horizontal scaling requires a shared cache backend (Redis via a custom cache handler) to avoid cache inconsistency between instances. Use unstable_cache with a Redis adapter for self-hosted deployments where you need cross-instance cache sharing. For the full picture of how Next.js handles JSON responses, see the companion guide on JSON API design.
Key Terms
- cache-aside
- Also called lazy loading — the application checks the cache before querying the data source. On a cache miss, it fetches from the data source, writes the result to the cache with a TTL, and returns the result. The cache contains only data that has been requested at least once; it is never pre-populated. Cache-aside is the most common Redis caching pattern because it is simple to implement, naturally avoids caching unused data, and allows independent TTLs per entity. The downside is that the first request after a miss (or after a TTL expiry) always pays the full database round-trip cost. Concurrent misses on the same key cause a cache stampede — mitigated with a mutex lock or probabilistic early expiry.
- write-through
- A caching strategy where every write operation updates both the cache and the underlying data store before returning to the caller. The cache is always in sync with the database — there is no window where the cache contains stale data from a recent write. Write-through eliminates cold-start cache misses that plague cache-aside on the first read after a write. The trade-off is increased write latency: both the database write and the cache write must complete before the response is returned. On modern cloud infrastructure (database ~10ms, Redis ~0.3ms) the overhead is typically under 5%. Contrast with write-behind (also called write-back), where the cache is updated immediately and the database is updated asynchronously — lower write latency but risk of data loss on crash.
- ETag
- An HTTP response header containing a hash or version identifier of the response body — for example,
ETag: "d41d8cd98f00b204". The client caches the ETag alongside the response body and sends it in theIf-None-Matchrequest header on subsequent requests. If the ETag matches the current resource state, the server responds with304 Not Modifiedand no body, saving all bandwidth for the payload. If the resource has changed, the server returns200with the new body and a new ETag. Strong ETags ("abc123") guarantee byte-for-byte identity. Weak ETags (W/"abc123") indicate semantic equivalence — suitable when JSON field ordering may vary across serializations of the same data. - Cache-Control
- An HTTP response (and request) header that specifies caching directives for browsers, proxies, and CDNs. Key directives:
public— any cache may store the response;private— only the end-user browser may cache;max-age=N— the response is fresh for N seconds;s-maxage=N— overrides max-age for shared caches (CDNs) only;no-store— never cache;no-cache— always revalidate before serving;must-revalidate— do not serve stale after max-age;stale-while-revalidate=N— serve stale for N seconds while refreshing in background. For JSON APIs, the most common production value ispublic, max-age=60, s-maxage=300, stale-while-revalidate=60. - TTL
- Time-to-live — the duration (in seconds) for which a cache entry is considered valid. In Redis, TTL is set with
SETEX key seconds valueorSET key value EX seconds. After the TTL elapses, the key is automatically deleted from Redis and the next read triggers a cache miss. TTL is the primary mechanism for eventual consistency in cache-aside: setting TTL = 300 means the cache may serve data that is up to 5 minutes stale. Shorter TTLs increase cache miss rate and database load; longer TTLs increase the staleness window. TTL should be proportional to the update frequency of the underlying data: static configuration — hours; product catalogs — minutes; real-time data — seconds or no cache. - stale-while-revalidate
- A
Cache-Controlextension directive that instructs caches to serve a stale (expired) response immediately while triggering a background fetch to refresh the cache asynchronously. The syntax isCache-Control: max-age=60, stale-while-revalidate=300— fresh for 60 seconds, then stale-but-served for 300 more seconds during background refresh, then wait for origin. The end user experiences zero latency on cached responses even after TTL expiry. Implemented natively in Cloudflare, Fastly, and Vercel CDNs. Next.js implements the same pattern in its data cache vianext: { revalidate: N }. Defined in RFC 5861. - RedisJSON
- A Redis module (part of Redis Stack) that adds native JSON as a first-class data type. Key commands:
JSON.SET key path value— store a JSON document or update a sub-path;JSON.GET key path— retrieve the full document or a JSONPath sub-path without loading the rest;JSON.MGET key1 key2 path— fetch multiple documents in one round-trip;JSON.NUMINCRBY key path increment— atomically increment a numeric field;JSON.ARRAPPEND key path value— atomically append to a JSON array. The main advantage over string-based caching is atomic partial updates and partial reads without client-side read-modify-write cycles. Available in Redis Stack (Docker:redis/redis-stack) and Redis Cloud. - cache invalidation
- The process of removing or marking as stale a cache entry when the underlying data changes, ensuring subsequent reads reflect the new state. Four main strategies: (1) TTL expiry — entries age out automatically, simplest but accepts staleness up to the TTL window; (2) event-driven delete — call
redis.del(key)on every write, immediate consistency but requires tracking all affected keys; (3) tag-based invalidation — group related keys under a tag, purge all keys with a given tag in one operation; (4) versioned keys — embed a version counter in the key, bump on change so old keys become unreachable without explicit deletion. CDN providers support tag-based invalidation via Cloudflare Cache-Tag and Fastly Surrogate-Key headers.
FAQ
How do I cache JSON API responses with Redis?
Use the cache-aside pattern: check Redis with GET key before querying the database. On a hit, deserialize with JSON.parse(cached) and return. On a miss, fetch from the database, store with SETEX key ttl JSON.stringify(data), and return. Design cache keys to encode entity type and ID: user:123 for single records, products:category:electronics:page:2 for parameterized queries. Redis GET/SET for a 1KB JSON object takes under 0.1 ms on a local network — roughly 2000x faster than a database query. For large documents where you read only a subset of fields, use the RedisJSON module with client.json.get(key, { path: "$.fieldName" }) to avoid loading and deserializing the full payload. Prevent cache stampede (concurrent misses all hitting the database) with a Redis mutex lock: set a lock key with SET lockKey 1 EX 5 NX, fetch, release the lock. Other concurrent requests retry from cache after a short wait.
What is the difference between cache-aside and write-through caching?
Cache-aside populates the cache on read: the application checks the cache first, fetches from the database on a miss, and writes the result to cache with a TTL. The cache contains only data that has been requested — it is never pre-populated. Cold-start misses (first request after TTL expiry) always hit the database. Write-through populates the cache on every write: both the cache and the database are updated before returning to the caller. The cache is always warm — no cold-start misses. Write-through doubles write latency (database write + cache write), though on modern infrastructure (database ~10ms, Redis ~0.3ms) the overhead is under 5%. Use write-through when reads are frequent and staleness immediately after writes is unacceptable — user profiles, order status. Use cache-aside for read-heavy workloads where occasional cold-start misses are acceptable and TTLs manage freshness automatically.
How do HTTP ETags work for JSON API caching?
The server hashes the JSON response body — typically MD5 or SHA-256 of JSON.stringify(data) — and returns it in the ETag response header: ETag: "d41d8cd98f00b204". The client caches the response body alongside the ETag. On subsequent requests, the client sends the cached ETag in the If-None-Match header. If the resource is unchanged, the server returns 304 Not Modified with no response body — saving all bandwidth for the payload. If the resource has changed, the server returns 200 with the new body and a new ETag. ETags are most valuable when data changes at unpredictable intervals: set max-age=0, must-revalidate so the browser always validates, but 304 responses avoid re-downloading unchanged content. Recomputing the hash on each request is cheap (under 1 ms for a 10KB payload) compared to database reads. Weak ETags (W/"abc123") signal semantic equivalence — use them when JSON field ordering may vary across serializations of the same data.
What TTL should I set for JSON caching?
TTL should match the acceptable staleness window for each data type. Guidelines: static configuration (feature flags, localization strings) — 1–24 hours; product catalogs and pricing — 5–15 minutes; user profiles and account data — 1–5 minutes; search results and feeds — 30–60 seconds; real-time inventory or financial data — 0 (no cache) or 5–15 seconds with stale-while-revalidate. A good starting rule: set TTL to one-tenth of the average update frequency — if data changes every 10 minutes on average, start with a 60-second TTL. Monitor cache hit rate and increase TTL until hits plateau above 80%. Design cache keys to isolate fast-changing from slow-changing fields — store a user's static profile (5-minute TTL) separately from their live notification count (15-second TTL) rather than combining them in one key with the shorter TTL. Cold-start blast radius (the number of requests hitting the database while cache warms up after a flush) grows with TTL — factor this into capacity planning.
How do I invalidate a JSON cache when data changes?
Four strategies address different trade-offs: (1) TTL expiry — simplest; let entries age out naturally. Acceptable when a few minutes of staleness has no consequence. (2) Event-driven delete — call redis.del("user:123") immediately after every write. Most consistent for single-entity caches, but requires enumerating all affected keys in the write path. Use a Redis pipeline to delete multiple related keys atomically. (3) Tag-based invalidation — track related cache keys under a Redis Set keyed by entity tag; call redis.del(...keysInTag) on data change. CDN providers support this natively via Cloudflare Cache-Tag and Fastly Surrogate-Key. (4) Versioned keys — embed a version counter in the cache key (products:v:42); on change, increment the version so all reads miss to the new key. Old keys expire via TTL without explicit deletion. Combine strategies: event-driven delete for precisely trackable entities, TTL for aggregate and search caches where key enumeration is impractical.
What is stale-while-revalidate and how does it work with JSON?
stale-while-revalidate is a Cache-Control extension that allows caches to serve an expired response immediately while fetching a fresh copy in the background.Cache-Control: max-age=60, stale-while-revalidate=300 means: serve fresh for 60 seconds, then serve stale (with a background refresh request) for 300 more seconds, then block and wait for a fresh response after 360 seconds total. The end user always receives a cached response in under 1 ms — the background refresh is completely invisible to the request. For JSON APIs this eliminates the latency spike when a popular endpoint expires under load: instead of all concurrent requests blocking on a database query, one background request refreshes the cache while all others continue to receive the cached JSON. Next.js implements the same semantics via fetch(url, { next: { revalidate: 60 } }) — stale JSON is served and the cache revalidates after 60 seconds. Use for public product catalogs, news feeds, and public profile APIs. Avoid for inventory, pricing, and any data where stale values cause incorrect downstream behavior.
How do I use Next.js fetch cache for JSON responses?
Next.js App Router extends fetch() with a next option: fetch(url, { next: { revalidate: 60 } }) caches the JSON response and revalidates every 60 seconds using ISR semantics — stale responses are served while a background request refreshes the cache.{ cache: "force-cache" } stores the response permanently until a full rebuild or on-demand revalidation.{ cache: "no-store" } skips all caching for real-time data. For non-fetch data sources (databases, Redis, third-party SDKs), use unstable_cache from next/cache: wrap the async function, provide a cache key and tags, and set revalidate in seconds. Trigger on-demand revalidation after mutations with revalidateTag("products") or revalidatePath("/products") in a Server Action or Route Handler. On Vercel, the Next.js data cache is stored at the edge and revalidations propagate globally in seconds. On self-hosted deployments, cache entries are stored on the filesystem — add a Redis cache handler for cross-instance cache sharing in horizontally scaled deployments.
What is RedisJSON and how does it differ from storing JSON as a string in Redis?
RedisJSON (part of Redis Stack) adds native JSON as a first-class data type in Redis. Storing JSON as a string with SET/GET requires JSON.stringify() before every write and JSON.parse() after every read — the full document is serialized and deserialized even when you only need one field. RedisJSON stores the document in a binary tree internally and supports atomic partial reads with JSONPath: JSON.GET key $.user.name fetches only the name field without loading or deserializing the rest of the document. Partial writes are also atomic: JSON.NUMINCRBY key $.stock -1 decrements the stock field without a read-modify-write cycle, eliminating race conditions that affect the string-based approach.JSON.MGET key1 key2 $ fetches multiple documents in one round-trip. The performance advantage is most pronounced for large documents (10KB+) where reads consistently access only a small subset of fields. For small documents (under 1KB) where you always read the full object, the string approach is simpler and nearly as fast. RedisJSON is available in Redis Stack (self-hosted: docker run redis/redis-stack) and all Redis Cloud tiers.
Further reading and primary sources
- MDN: HTTP Caching — Complete HTTP caching reference: Cache-Control directives, ETag, Vary, and conditional requests
- Redis: JSON data type (RedisJSON) — Native JSON storage and JSONPath queries in Redis without serialization overhead
- web.dev: stale-while-revalidate — Detailed explanation of stale-while-revalidate behavior and when to apply it
- Next.js: Data Fetching and Caching — Official Next.js App Router documentation for fetch cache options, unstable_cache, and on-demand revalidation
- RFC 5861: HTTP Cache-Control Extensions for Stale Content — The RFC defining stale-while-revalidate and stale-if-error Cache-Control extensions