Custom Cache Handler in Next.js 16 — Redis Setup Caused 200ms Added Latency Per Request
A misconfigured Redis cache handler in Next.js 16 added 200ms latency per request.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Node.js 18+
- ✓Next.js 16 project with App Router
- ✓Basic understanding of key-value stores (Redis, KV)
- A custom Redis cache handler in Next.js 16 added 200ms per request — the default in-memory cache was 100x faster for single-instance deployments
- cacheLife profiles set TTL per data type: cacheLife({ stale: 60, revalidate: 300, expire: 3600 }) for ISR-like control
- cacheTag enables granular invalidation: tag a fetch('url', { next: { tags: ['products'] } }) and revalidateTag('products') anywhere
- Stale-while-revalidate returns cached data immediately and refreshes in background — never block the response on cache rebuild
- The default in-memory FS cache is faster than Redis for single-server deployments — Redis only helps when shared across multiple instances
Imagine a restaurant kitchen that, instead of keeping cooked dishes on a nearby counter, sends every order to a warehouse across the street. The warehouse stores food, yes — but the delivery time to cross the street cancels out any benefit. That's what happens when a single-server Next.js app sends every cache lookup to a remote Redis instance. The default in-memory cache is the counter in the same kitchen — orders are fulfilled in milliseconds, not 200ms round trips across the network.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Caching is the backbone of Next.js performance. Every ISR-revalidated page, every cached fetch response, every data request served from cache — they all go through Next.js's cache layer. In Next.js 14 and earlier, this cache was a simple in-memory store on the filesystem, adequate for single-instance deployments but invisible to developers.
Next.js 16 introduced a configurable cache handler API. You can now plug in Redis, Memcached, Cloudflare KV, or any key-value store as the backing cache. This enables shared caching across multiple server instances — critical for auto-scaling deployments where each instance would otherwise rebuild its own cache.
But with great power comes a new failure mode: a poorly configured external cache adds 50-200ms of latency to every cache operation. If your cache handler adds more latency than it saves by avoiding data source fetches, you have built a slower system than running with no cache at all.
This article covers the custom cache handler API, cacheLife profiles for declarative TTL management, cacheTag for granular invalidation, stale-while-revalidate configuration, and the specific scenarios where external caching helps versus hurts. By the end, you will know when to deploy Redis and when the default filesystem cache is the right choice.
The Default Cache — Why Local FS Is Faster Than You Think
Next.js's default cache handler stores data as files on the local filesystem. This sounds primitive compared to a managed Redis instance, but for single-server deployments, it is faster. Local file I/O takes 0.5-2ms per operation. A Redis round trip on the same machine via Unix socket takes 1-3ms. A Redis round trip over a network connection takes 5-50ms depending on distance and bandwidth.
The default handler also has zero operational overhead. There is no Redis to deploy, no connection pool to configure, no eviction policy to tune. The cache is automatically available when your Next.js server starts and disappears when it stops — acceptable for ephemeral server instances.
The limitation: the filesystem cache is not shared across instances. If you run 4 server instances behind a load balancer, each instance has its own cache. A request served by instance 1 that triggers a revalidation does not benefit instance 2 — instance 2 must independently re-fetch and cache the data. For small deployments (1-3 instances), this is acceptable. For larger deployments, a shared external cache eliminates redundant work.
The rule: default to the filesystem cache. Only add an external cache when you can measure that the cache hit rate benefit exceeds the added latency of remote network calls.
cacheLife Profiles — Declarative TTL Management Without Boilerplate
Before cacheLife, managing cache TTLs required either hardcoding values in every fetch call or building a custom TTL function. cacheLife changes this by letting you define reusable profiles that specify how long data can be stale, how long before revalidation triggers, and when data expires completely.
Each profile defines three timestamps: stale — how long the cache serves data without checking the origin; revalidate — how long before Next.js triggers a background revalidation; and expire — the absolute maximum time data remains in cache before being treated as a miss. Together, these mirror the stale-while-revalidate pattern that ISR popularised.
Profiles are registered via cacheLife() in a layout or page file. Once registered, any fetch or data call in that route segment can reference the profile by name. Next.js automatically manages the TTL logic — you don't need to pass revalidation times to every fetch call.
The practical benefit: one source of truth for how long each data type is cached. Product data gets a different profile than user preferences or analytics dashboards. Changing the profile updates all consumers automatically.
{ next: { revalidate: 300 } } had 5 different revalidate values and 3 bugs where TTLs didn't match the data's actual freshness requirements. Consolidating to 4 cacheLife profiles eliminated all TTL discrepancies. Changing the 'products' profile from 5-minute to 15-minute stale time updated every product fetch across the app in one line. Rule: cacheLife profiles are the single source of truth for caching TTLs.fetch() calls — change one profile, update all consumers.cacheTag — Granular Invalidation Without the Pain
Time-based revalidation is a blunt instrument. With cacheLife's revalidate window, you accept that data may be up to N minutes stale. For many apps, this is fine. For apps where data changes unpredictably — a CMS publish, a price update, a user action — time-based invalidation is either too slow (users see stale data) or too aggressive (server constantly revalidates).
cacheTag solves this by letting you tag cached responses with arbitrary strings and invalidate them on demand. You tag a fetch call with tags: ['products', 'category-electronics']. Then, anywhere in your app — in an API route, a server action, a webhook handler — you call revalidateTag('products') and every cached response tagged with 'products' is immediately invalidated.
The tag system supports arbitrary granularity. You can tag broadly (tags: ['products']) for full category invalidation, or specifically (tags: ['product-42']) to invalidate a single item. You can also combine multiple tags on one fetch — invalidating any of the tags triggers revalidation.
The performance implication: revalidation is lazy. Calling revalidateTag marks the cached response as stale but does not rebuild it. The next request that hits that cache entry triggers the background regeneration. This means invalidating 10,000 product tags is instant — it's just a metadata update. The actual work happens over time as users visit invalidated pages.
cacheTag('Products') will NOT be invalidated by revalidateTag('products'). Tags are case-sensitive strings. Standardise on lowercase tags and enforce via linting.revalidateTag('articles') made updates appear in <1 second. Rule: use cacheTag for user-triggered data updates (CMS publish, price change) and cacheLife for automatic background freshness.Building a Custom Cache Handler — When and How
The custom cache handler API in Next.js 16 lets you replace the default filesystem cache with any key-value store. The handler is a class that implements get, set, has, delete, and revalidateTag methods. Next.js calls these methods for every cache operation during rendering.
When you need a custom handler: (1) multi-instance deployments where shared caching reduces redundant work; (2) persistence requirements where cache must survive server restarts; (3) edge caching with Cloudflare KV or similar; (4) specialised backends like Memcached for very large cached objects.
When you don't: single-instance deployments, serverless functions with short lifetimes, or any scenario where the added latency of external network calls may exceed the cache hit benefit. Default to filesystem, measure, then customise.
The handler configuration is in next.config.ts via the cacheHandler option pointing to a module path. The module must export a default class or function that Next.js can instantiate. The handler receives configuration options from the config file.
revalidateTag method initially used KEYS * pattern matching — a Redis anti-pattern that blocks the event loop on large datasets. They replaced it with a secondary index in Redis (a SET per tag storing associated keys). Rule: use HTTP-based KV stores for serverless; maintain a tag index for efficient revalidation.Stale-While-Revalidate — Never Block on Cache Miss
The stale-while-revalidate pattern is the most important caching concept of the past decade. It works: serve the cached (stale) response immediately to the user, then in the background, re-fetch the fresh data and update the cache. The user never waits for the origin.
Next.js has supported this implicitly via ISR's background regeneration since Next.js 9.5. But cacheLife profiles make it explicit: the stale window is the 'while-revalidate' period — the cache is allowed to return stale data while a background refresh completes. The revalidate window is the trigger point — when elapsed time exceeds revalidate, the next request triggers a background refresh.
The critical detail: during the background refresh, the cache still returns stale data. The user whose request triggered the revalidation receives stale data — not fresh. Only subsequent requests receive the freshly built response. This is correct behaviour: no user should ever be blocked waiting for a cache rebuild.
Where teams go wrong: they treat stale data as a bug and reduce the stale window to near-zero. This defeats the purpose — if stale is 1 second, almost every request triggers a background refresh, turning ISR into a more expensive version of SSR. The stale window exists precisely to absorb traffic spikes without overwhelming the origin.
stale: 0 means every request triggers a background refresh. Under 10,000 req/s, this floods your origin with 10,000 simultaneous revalidation requests. Keep the stale window at least 30-60 seconds to allow deduplication — Next.js coalesces concurrent revalidation requests for the same key.stale: 10, revalidate: 10 (identical values), intending 10-second freshness. In practice, every request arrived with elapsed time exceeding both values, triggering a background refresh on every request. Their origin received 10,000 req/s of revalidation traffic. The fix: stale: 60, revalidate: 300 — a 5:1 ratio between stale and revalidate windows absorbed traffic spikes. Rule: the stale window should be 3-5x larger than the revalidate window to absorb traffic spikes.Redis vs Memcached vs Cloudflare KV — Choosing the Right Backend
Not all external caches are equal. Each has specific characteristics that make it suitable for different deployment patterns.
Redis: full-featured in-memory store. Supports data structures (sets, hashes, sorted sets), TTL, pub/sub for cache invalidation broadcasts, and Lua scripting for atomic operations. Best for: multi-instance deployments where you need shared cache + pub/sub invalidation + data structure operations. Overhead: 5-15ms per operation for network round trip plus serialization.
Memcached: simpler, faster, more memory-efficient per byte of cached data. No persistence, no data structures, no TTL beyond max-age. Best for: very large cache datasets where memory efficiency matters more than features. Overhead: 3-10ms per operation, lower memory overhead per cached item than Redis.
Cloudflare KV: globally distributed, eventually consistent key-value store at the edge. Reads are served from the nearest edge location (sub-5ms p99). Writes propagate globally within 60 seconds. Best for: multi-region Next.js deployments where cache must be close to users worldwide. Not suitable for: data that requires strong consistency or sub-second write propagation.
The choice rule: Redis for single-region multi-instance, KV for global multi-region, Memcached for large cache datasets on a budget. If you can't decide, start with Redis — it's the most forgiving of the three.
Cache and Server Actions — Stale Data and Optimistic Updates
Server Actions in Next.js 16 add a new wrinkle to caching. When a user submits a form that mutates data, the Server Action runs on the server. After the mutation completes, Next.js can revalidate cached data automatically — but only if you tell it to.
The pattern: the Server Action performs the mutation, then calls revalidatePath() or revalidateTag() to mark affected cached data as stale. The next request for the page that displays the mutated data triggers a background refresh, returning the new data.
For a snappier user experience, pair Server Actions with optimistic updates on the client. The UI updates immediately to show the expected result (optimistic), while the Server Action runs in the background. If the action fails, the UI reverts to the previous state. If it succeeds, the revalidation refreshes the display with server-authoritative data.
The caching challenge: Server Actions run on the server, so they can directly call revalidateTag. But if you use a library like React Query or SWR alongside cacheTag, you have two layers of caching — Next.js's server-side cache and the client-side cache. Invalidate both when mutations occur.
revalidatePath when you know the exact route to invalidate (e.g., the current page). Use revalidateTag when you need to invalidate all pages that consume the same data (e.g., a product update affects the product page, category page, and search results). Both are lazy — they mark stale, not rebuild.revalidatePath('/products') — but the product detail pages were at /products/[slug]. The category listing at /products revalidated, but individual product pages remained stale for up to 15 minutes. The fix: calling revalidateTag('products') instead of revalidatePath invalidated all pages tagged with 'products', including individual detail pages and search results. Rule: use revalidateTag for data-scoped invalidation, revalidatePath for route-scoped invalidation.Cache Deduplication and Coalescing — How Next.js Prevents Thundering Herds
The thundering herd problem: when a cached page expires or is invalidated, and 10,000 users simultaneously request it, all 10,000 requests could trigger individual revalidation fetches to the origin. This can overwhelm your database, API, or CMS.
Next.js handles this through request deduplication: when a background refresh is triggered for a cache key, Next.js coalesces concurrent requests. Of 10,000 simultaneous requests, only ONE triggers the actual revalidation. The other 9,999 receive the stale cached response while the single revalidation completes. When the fresh response is ready, it replaces the cached entry and all subsequent requests receive fresh data.
This deduplication happens within a single server instance. Across multiple instances (without shared caching), each instance independently deduplicates — but you could still have 4 revalidation requests for the same key hitting your origin simultaneously with 4 instances. This is where shared caching (Redis/KV) with a mutex lock prevents each instance from independently revalidating.
The deduplication timeout is the key: if the background refresh takes 1 second, all requests during that second see stale data. If the refresh takes 10 seconds (slow API), requests see stale data for 10 seconds. The system is resilient to slow data sources — it never blocks requests waiting for fresh data.
Cache Monitoring — What to Measure and What to Alert On
Without monitoring, your cache is a black box. You don't know your hit rate, miss penalty, revalidation latency, or eviction rate. You can't answer the basic question: is caching helping or hurting?
The metrics to track: (1) Cache hit rate — percentage of requests served from cache. Target: >80% for most apps, >95% for content-heavy apps. Below 50% indicates misconfigured TTLs or incorrect cache keys. (2) Cache latency — time per cache operation. Local FS: 0.5-2ms. Remote Redis: 5-15ms. If remote cache latency exceeds your target response time margin, the cache is hurting. (3) Revalidation count — how many background refresh requests hit your origin per cache key. Spikes indicate TTLs are too short or invalidation is too aggressive. (4) Cache size and eviction rate — how much data is in the cache and how often entries are evicted before their TTL. High eviction means the cache is too small for your working set.
Alert on: cache hit rate dropping below 60% for 5 minutes, revalidation count exceeding 100/min for a single key (potential thundering herd despite deduplication), cache latency p99 exceeding 50ms.
Production Migration — From Default FS to Redis Without Downtime
Migrating from the default filesystem cache to a custom Redis handler in production requires a careful rollout. A sudden switch can cause a cold cache event — every user request becomes a cache miss, flooding your origin with traffic that the cache previously absorbed.
The safe migration strategy: (1) Deploy the Redis handler alongside the FS handler — run both in parallel with the Redis handler writes-only. All reads continue from FS cache. The Redis handler populates over time without serving traffic. (2) After one TTL cycle (monitor for 2x your longest cacheLife expire value), switch Redis to read-write and FS to writes-only. Cache hits now primarily serve from Redis. (3) After another TTL cycle, remove the FS handler entirely.
Each phase should run for at least one full cache expire cycle of your longest cache profile. For a profile with expire: 86400 (24 hours), each phase takes 24 hours minimum — a 72-hour migration. This is intentionally conservative: a cold cache can take down a production system.
The Redis Cache That Slowed Everything Down — 200ms per Request
CACHE_BACKEND=redis env var is set. Added request timing instrumentation: x-cache-timing: 2ms header on every response so future developers can see cache latency. Documented the deployment scenarios where Redis is necessary: multi-instance auto-scaling, region-level caching with Cloudflare KV, or when the cache must survive server restarts.- External cache is NOT inherently faster than local cache — network round trips add 10-50ms per operation that local FS cache does not incur
- Measure cache latency in production before and after any cache infrastructure change — add
x-cache-timingheaders to every response - For single-instance deployments, the default filesystem cache is optimal — only add Redis/Memcached when you have 2+ server instances sharing cache state
- Cache handler operations multiply: cache check + read + write + revalidation = 3-4 round trips per request. 1ms per operation local vs 15ms per operation remote adds up fast
x-cache-timing headers to measure cache operation latency. Compare speedtest results between your cache backend and local FS. A remote cache that adds >10ms per operation is slower than default FS cache for single-instance appsrevalidateTag() is called with the exact tag string used in fetch().next.tags. Tags are case-sensitive. Check that your cache handler implements the revalidateTag method — the default FS handler does, but custom handlers may skip itx-cache-ttl: stale=60, revalidate=300curl -sI https://your-app.com/page | grep -i 'x-cache\|x-vercel-cache'redis-cli --raw info stats | grep 'keyspace_hits\|keyspace_misses'| File | Command / Code | Purpose |
|---|---|---|
| app | cacheLife({ | cacheLife Profiles |
| app | export default async function ProductsPage() { | cacheTag |
| cache | interface CacheValue { | Building a Custom Cache Handler |
| cache.ts | cacheLife({ | Stale-While-Revalidate |
| cache | export default class KVCacheHandler implements CacheHandler { | Redis vs Memcached vs Cloudflare KV |
| app | 'use server' | Cache and Server Actions |
| cache | const inflightRefreshes = new Map | Cache Deduplication and Coalescing |
| lib | 'use server' | Cache Monitoring |
| cache | export default class MigrationHandler implements CacheHandler { | Production Migration |
Key takeaways
fetch() callsInterview Questions on This Topic
You deploy a Next.js 16 app to 4 server instances behind a load balancer. Without a shared cache, each instance independently builds its own cache. Walk me through how you would design a custom cache handler with Redis, including the metrics you'd use to verify it's an improvement.
x-cache: HIT|MISS|x-cache-latency: Nms headers to every response for real-time visibility. The metric that matters: origin request rate decreasing by at least the same percentage as the cache miss rate decreased. If origin requests don't drop, the shared cache added complexity without value.
The rollout: dual-write handler (write to both FS and Redis, read from FS) for one TTL cycle to warm the Redis cache, then switch reads to Redis with FS fallback for safety.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's Next.js. Mark it forged?
8 min read · try the examples if you haven't