Home JavaScript Custom Cache Handler in Next.js 16 — Redis Setup Caused 200ms Added Latency Per Request
Advanced 8 min · July 12, 2026
Custom Cache Handlers: Redis, Memcached, and Distributed Caching

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.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
Production
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
Before you start⏱ 25 min
  • Node.js 18+
  • Next.js 16 project with App Router
  • Basic understanding of key-value stores (Redis, KV)
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is Custom Cache Handlers?

A custom cache handler in Next.js 16 is a pluggable backend for the framework's incremental cache — the layer that stores rendered pages, fetch responses, and data caches. By default, Next.js stores cached data on the local filesystem, which is fast and zero-configuration but ephemeral and instance-local.

Imagine a restaurant kitchen that, instead of keeping cooked dishes on a nearby counter, sends every order to a warehouse across the street.

A custom handler lets you replace this with any key-value store: Redis, Memcached, Cloudflare KV, or a custom database-backed solution.

The handler is a class that implements five methods: get (retrieve a cached value by key), set (store a value with key and optional tags), has (check if a key exists), delete (remove a key), and revalidateTag (invalidate all entries with a given tag). Next.js calls these methods throughout the rendering lifecycle: checking the cache before fetching data, storing rendered pages, and invalidating stale entries when tags are revalidated.

The power of custom handlers is shared caching across server instances. Without a shared cache, each instance of a multi-instance deployment builds its own cache independently — a page requested on instance 1 generates HTML and caches it, but the same page requested on instance 2 is a cache miss and must be regenerated.

With a shared Redis cache, instance 2 serves the HTML cached by instance 1, reducing server load and improving consistency.

The complexity cost is real: the handler must handle serialization, connection pooling, error recovery, and network latency. A handler that adds 50ms per cache operation negates the benefit of caching unless the hit rate improvement is substantial. Start with the default FS cache, measure, and only introduce external caching when the data justifies the complexity.

Plain-English First

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.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

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.

Local vs Shared Caching
Think of cache like an office kitchen: the default FS cache is a mini-fridge at your desk — fast but personal. A shared Redis cache is the main fridge downstairs — accessible to everyone but requires a trip. For a single-person office (one server), the mini-fridge wins. For a 50-person office (multiple servers), the shared fridge reduces total work even though each trip takes longer.
Production Insight
A 3-instance deployment without shared caching saw 67% cache hit rate (each instance built its own cache). Adding Redis with 2ms network latency brought hit rate to 94% — cached data was now shared — but added 6ms per request (3 cache operations × 2ms). The net impact was a 20% improvement in p95 latency because 27% more requests were served from cache. Always measure before architecting: the hit rate gain must exceed the latency overhead.
Key Takeaway
Default filesystem cache is 0.5-2ms per operation — faster than any remote cache for single-instance deployments.
Shared caching (Redis/Memcached/KV) only helps when 2+ instances serve the same data redundantly.
Measure cache hit rate AND latency before and after: external cache is worth it only if hit rate gain > latency overhead.
nextjs-custom-cache-handlers THECODEFORGE.IO Next.js 16 Cache Architecture with Redis Handler Layered components from client to cache storage Client Layer Browser | Mobile App | API Client Next.js 16 Server Server Actions | Route Handlers | Cache API Cache Abstraction Layer cacheLife Profiles | cacheTag Invalidation | Stale-While-Revalidate Custom Cache Handler Redis Client | Connection Pool | Serialization Cache Storage Redis Cluster | Local Filesystem | Cloudflare KV THECODEFORGE.IO
thecodeforge.io
Nextjs Custom Cache Handlers

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.

app/layout.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { cacheLife } from 'next/cache'

// Register cache profiles in the root layout
// These apply to all pages in the app
cacheLife({
  // Data that changes hourly:
  products: {
    stale: 60 * 5,      // Serve stale for 5 min
    revalidate: 60 * 15, // Background refresh after 15 min
    expire: 60 * 60,     // Hard expiry at 1 hour
  },
  // Data that changes daily:
  blog: {
    stale: 60 * 60,     // Serve stale for 1 hour
    revalidate: 60 * 60 * 6, // Refresh every 6 hours
    expire: 60 * 60 * 24,   // Expire after 1 day
  },
  // User-specific data — never cache long:
  session: {
    stale: 60,          // Stale for 1 min
    revalidate: 60 * 5,  // Refresh every 5 min
    expire: 60 * 30,     // Hard expiry at 30 min
  },
})

// Usage in any page:
// const data = await fetch(url, {
//   next: { revalidate: false }  // Opt out of per-request revalidate
// })
// The cacheLife profile handles the TTL
Try it live
Profile Registration Order Matters
cacheLife profiles must be registered before any fetch/component that uses them. Register profiles in the root layout.tsx so they are available to every page and component in the tree. Registering in a nested layout limits the profile to that route segment.
Production Insight
A team with 12 different fetch calls each manually specifying { 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.
Key Takeaway
cacheLife profiles define stale, revalidate, and expire timestamps per data type — one source of truth for cache TTLs.
Register profiles in the root layout so they're available to all route segments.
cacheLife eliminates hardcoded revalidate values across 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.

app/products/page.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { unstable_cacheTag as cacheTag } from 'next/cache'

// Server Component fetching products
export default async function ProductsPage() {
  const data = await fetch('https://api.example.com/products', {
    next: {
      tags: ['products'], // Tag this response for invalidation
    },
  })
  const products = await data.json()

  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

// --- app/api/revalidate/route.ts ---
// Webhook or admin action to invalidate cache
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const body = await request.json()

  // Invalidate all product caches
  revalidateTag('products')

  // Or invalidate a specific product
  // revalidateTag(`product-${body.productId}`)

  return Response.json({ revalidated: true })
}
Try it live
revalidateTag Is Case-Sensitive
cacheTag('Products') will NOT be invalidated by revalidateTag('products'). Tags are case-sensitive strings. Standardise on lowercase tags and enforce via linting.
Production Insight
A CMS-driven site used time-based revalidation every 60 seconds for all pages. After a breaking news event, editors published updates that took up to 60 seconds to appear — an eternity for news. Switching to cacheTag with a CMS webhook that called 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.
Key Takeaway
cacheTag enables on-demand invalidation — tag cached responses and invalidate them anywhere via revalidateTag().
Time-based revalidation is for predictable freshness needs; cacheTag is for event-driven invalidation (CMS publish, webhook).
Tags support arbitrary granularity — broad ('products') or specific ('product-42') — and invalidation is lazy (marks stale, rebuilds on next request).
Redis vs Local FS for Next.js 16 Cache Handler Trade-offs between distributed and local caching Redis Cache Handler Local FS Cache Handler Per-Request Latency 200ms added (network round-trip) <1ms (local disk read) Cache Invalidation Instant via cacheTag (Redis pub/sub) File deletion (slower for many tags) Scalability Shared across multiple servers Per-server, not shared Data Persistence Persistent (RDB/AOF snapshots) Ephemeral (cleared on restart) Complexity Requires Redis setup and connection mana Built-in, no extra dependencies THECODEFORGE.IO
thecodeforge.io
Nextjs Custom Cache Handlers

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.

cache/redis-handler.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import { Redis } from '@upstash/redis'
import type { CacheHandler } from 'next/dist/server/lib/incremental-cache'

interface CacheValue {
  body: string
  tags: string[]
}

export default class RedisCacheHandler implements CacheHandler {
  private redis: Redis
  private ttl: number

  constructor(options: { ttl?: number }) {
    this.redis = Redis.fromEnv()
    this.ttl = options.ttl ?? 300
  }

  async get(key: string) {
    const cached = await this.redis.get<CacheValue>(key)
    if (!cached) return null
    return {
      body: cached.body,
      tags: cached.tags,
      // Next.js needs to know when this was cached
      lastModified: Date.now(),
    }
  }

  async set(key: string, data: {
    body: string
    tags?: string[]
  }) {
    await this.redis.set(key, {
      body: data.body,
      tags: data.tags ?? [],
    }, { ex: this.ttl })
  }

  async has(key: string) {
    const exists = await this.redis.exists(key)
    return exists === 1
  }

  async delete(key: string) {
    await this.redis.del(key)
  }

  async revalidateTag(tag: string) {
    // Find all keys with this tag and delete them
    // In production, maintain a tag-to-key index
    const keys = await this.redis.keys(`*${tag}*`)
    if (keys.length > 0) {
      await this.redis.del(...keys)
    }
  }
}

// next.config.ts
// cacheHandler: './cache/redis-handler.ts'
Try it live
Prefer Upstash or KV SDKs Over Raw Clients
Upstash Redis and Cloudflare KV provide HTTP-based APIs with built-in TLS and connection pooling. Raw Redis clients (ioredis) require managing connections, reconnection logic, and TLS termination. For serverless environments, HTTP-based KV stores avoid cold-start connection overhead.
Production Insight
A team building a custom handler with ioredis discovered that every serverless cold start required a new Redis connection, adding 50-80ms to the first request. Switching to Upstash Redis (HTTP-based, no persistent connection) eliminated cold-start overhead. The handler's 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.
Key Takeaway
Custom cache handlers implement get/set/has/delete/revalidateTag methods — plug in any KV store.
HTTP-based KV stores (Upstash, Cloudflare KV) are better for serverless than raw TCP Redis clients.
revalidateTag requires an efficient tag-to-key index — avoid Redis KEYS pattern matching on production datasets.

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.

cache.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// cacheLife with explicit stale-while-revalidate semantics
cacheLife({
  // For 'products' cache profile:
  products: {
    // 5 minutes — serve stale data without checking origin
    // Users always get instant responses
    stale: 60 * 5,

    // 15 minutes — after this, trigger background refresh
    // The request that triggers the refresh still gets stale data
    revalidate: 60 * 15,

    // 1 hour — hard expiry. Cache miss, must fetch fresh.
    // This prevents serving truly outdated data if no one visited
    // during the revalidate window
    expire: 60 * 60,
  },
})

// Timeline of requests:
// T=0:    First request — cache miss, fetch from origin, populate cache
// T=3min: Request — cache HIT, serve stale, no revalidation
// T=16min: Request — stale > revalidate, serve STALE, trigger background refresh
// T=16.1s: Background refresh completes, cache updated
// T=17min: Request — cache HIT with FRESH data
// T=75min: No requests in 60min — cache EXPIREs
// T=80min: Request — cache MISS, fetch from origin
Try it live
The Stale Window Trap
Setting 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.
Production Insight
A team set 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.
Key Takeaway
stale-while-revalidate serves stale data instantly and refreshes in the background — never block the user on a cache rebuild.
The stale window must be 3-5x larger than revalidate to absorb traffic spikes without flooding the origin.
Setting stale near zero defeats the purpose — every request triggers a background refresh, turning ISR into expensive SSR.

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/kv-handler.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import type { CacheHandler } from 'next/dist/server/lib/incremental-cache'

// Cloudflare KV cache handler example
// Assumes @cloudflare/workers-types for KVNamespace type

export default class KVCacheHandler implements CacheHandler {
  private kv: KVNamespace

  constructor() {
    // In production, bind KV namespace from env
    // This example uses Cloudflare for Workers
    this.kv = (process.env as any).CACHE_KV
  }

  async get(key: string) {
    const value = await this.kv.get(key, 'json')
    if (!value) return null
    return {
      body: JSON.stringify(value),
      lastModified: Date.now(),
    }
  }

  async set(key: string, data: { body: string }) {
    await this.kv.put(key, data.body, {
      expirationTtl: 3600, // 1 hour
    })
  }

  async has(key: string) {
    const value = await this.kv.get(key)
    return value !== null
  }

  async delete(key: string) {
    await this.kv.delete(key)
  }

  async revalidateTag(tag: string) {
    // KV has no tag-based listing
    // Solution: maintain tag indexes or use a different
    // invalidation strategy (e.g., version prefixes)
    console.warn(`KV revalidateTag not implemented for: ${tag}`)
  }
}
Try it live
Multi-Region Cache Strategy
For global apps, use Cloudflare KV as the primary cache at the edge and Redis as the origin cache. KV serves cached responses in <5ms from the nearest edge location. If KV misses, fall back to Redis (shared across all instances in one region). If Redis misses, fall back to the origin. This creates a three-tier cache: edge → regional → origin.
Production Insight
A multi-region Next.js app used a single Redis instance in us-east-1 for all cache operations. Users in Europe experienced 80-120ms cache latency. Moving to Cloudflare KV with edge caching reduced cache latency to 5-15ms globally. The KV handler returned cached pages from the nearest edge location; only cache writes propagated to the central Redis. Rule: if your users are global, your cache must be global. Cloudflare KV provides the closest-to-user cache for reads.
Key Takeaway
Redis: full-featured, best for single-region multi-instance. Memcached: faster, lower memory overhead per item. Cloudflare KV: globally distributed edge caching.
Three-tier cache strategy: KV at edge → Redis in region → origin for global apps.
Cache backend choice depends on deployment geography, not feature preference.

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.

app/actions/product.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
'use server'

import { revalidateTag, revalidatePath } from 'next/cache'
import { db } from '@/lib/db'

// Server action: update product price
export async function updatePrice(
  productId: string,
  newPrice: number
) {
  // Perform mutation
  await db.product.update({
    where: { id: productId },
    data: { price: newPrice },
  })

  // Invalidate ALL product caches
  revalidateTag('products')

  // Also revalidate the specific product page
  revalidatePath(`/products/${productId}`)

  return { success: true }
}

// Client component with optimistic update
// 'use client'
// import { useOptimistic, useTransition } from 'react'
//
// export function PriceForm({ product }: { product: any }) {
//   const [pending, startTransition] = useTransition()
//   const [optimisticPrice, setOptimisticPrice] =
//     useOptimistic(product.price)
//
//   async function handleSubmit(formData: FormData) {
//     const newPrice = Number(formData.get('price'))
//
//     startTransition(async () => {
//       setOptimisticPrice(newPrice) // Immediate UI update
//       await updatePrice(product.id, newPrice)
//     })
//   }
//
//   return (
//     <form action={handleSubmit}>
//       <input name="price" defaultValue={optimisticPrice} />
//       <button type="submit" disabled={pending}>
//         {pending ? 'Saving...' : 'Update'}
//       </button>
//     </form>
//   )
// }
Try it live
revalidatePath vs revalidateTag
Use 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.
Production Insight
A team's Server Action mutated product data then called 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.
Key Takeaway
Server Actions should call revalidateTag or revalidatePath after mutations to invalidate cached data.
revalidateTag is data-scoped (invalidates all pages using that data); revalidatePath is route-scoped.
Pair with optimistic updates for instant UI feedback — revert on action failure.

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/deduplication.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Conceptual model of Next.js cache deduplication
// Not actual Next.js source code — illustrates the logic

const inflightRefreshes = new Map<string, Promise<void>>()

async function getOrRefresh(key: string) {
  // Check if a refresh is already inflight
  if (inflightRefreshes.has(key)) {
    // Don't start another refresh — wait for the existing one
    await inflightRefreshes.get(key)
    return serveStaleFromCache(key)
  }

  // Start refresh and store the promise
  const refreshPromise = fetchAndPopulateCache(key).finally(() => {
    inflightRefreshes.delete(key)
  })

  inflightRefreshes.set(key, refreshPromise)

  // Return stale data immediately — don't await the refresh
  return serveStaleFromCache(key)
}

// Result:
// Request 1: triggers refresh, gets stale data
// Request 2-10,000: see inflight refresh, join it, get stale data
// After refresh completes: all requests get fresh data
// Only 1 origin call for 10,000 requests
Try it live
One Refresh for 10,000 Users
The deduplication key insight: of 10,000 concurrent requests that trigger a refresh, only 1 contacts the origin. The other 9,999 get stale data. This is why stale-while-revalidate with coalescing is efficient: the cost is 1 revalidation per cache key, regardless of traffic volume.
Production Insight
During a product launch, 50,000 concurrent users hit a product page whose cache had just expired. Without deduplication, this would have been 50,000 simultaneous requests to the API. With Next.js deduplication, the API received exactly 1 revalidation request. The page served stale data for 800ms while the single refresh completed. Users experienced zero latency impact. Rule: Next.js deduplication prevents thundering herds on cache revalidation — it's automatic and requires no configuration.
Key Takeaway
Next.js deduplicates cache revalidation — 10,000 concurrent requests trigger only 1 origin call.
Deduplication is per-instance; shared caching with a distributed mutex prevents multi-instance thundering herds.
The deduplication window absorbs traffic spikes without overwhelming data sources — users get instant stale 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.

lib/cache-metrics.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
'use server'

import { Redis } from '@upstash/redis'

const redis = Redis.fromEnv()
const CACHE_PREFIX = 'next-cache:'

export async function getCacheMetrics() {
  const hits = await redis.get<number>(`${CACHE_PREFIX}stats:hits`) ?? 0
  const misses = await redis.get<number>(`${CACHE_PREFIX}stats:misses`) ?? 0
  const total = hits + misses

  return {
    hitRate: total > 0 ? (hits / total * 100).toFixed(1) : 0,
    totalOperations: total,
    hits,
    misses,
    // Sample latency from last 100 operations
    avgLatency: await redis.get<number>(`${CACHE_PREFIX}stats:avg-latency`),
  }
}

export async function recordCacheOperation(
  operation: 'hit' | 'miss',
  latencyMs: number
) {
  const key = `${CACHE_PREFIX}stats:${operation}s`
  await redis.incr(key)
  await redis.lpush(`${CACHE_PREFIX}stats:latencies`, latencyMs)
  await redis.ltrim(`${CACHE_PREFIX}stats:latencies`, 0, 99)

  // Calculate running average
  const latencies = await redis.lrange<number>(
    `${CACHE_PREFIX}stats:latencies`, 0, -1
  )
  const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length
  await redis.set(`${CACHE_PREFIX}stats:avg-latency`, avg)
}
Try it live
Don't Let Monitoring Become the Performance Problem
Instrumenting cache operations with Redis metrics on every request adds overhead. Use sampling (record 1 in 100 operations) or batch writes. The monitoring should never add more than 1ms per request — if it does, you're optimising the cache in a way that hurts performance.
Production Insight
A team added logging to every cache get/set operation for monitoring. The log I/O added 30ms per request, worsening the very problem they were trying to debug. Replacing synchronous logging with batched async writes reduced monitoring overhead to 2ms. Rule: cache monitoring must add less than 5% overhead to the cache system. Use sampling and batch writes.
Key Takeaway
Track cache hit rate (>80% target), cache latency (local <2ms, remote <15ms), revalidation count, and eviction rate.
Alert on hit rate <60%, revalidation count >100/min per key, and cache latency p99 >50ms.
Monitor with sampling and batch writes — don't let observability become the performance bottleneck.

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.

cache/migration-handler.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import type { CacheHandler } from 'next/dist/server/lib/incremental-cache'

// Dual-write cache handler for safe migration
// Phase 2: reads from Redis, writes to both

export default class MigrationHandler implements CacheHandler {
  private fs: CacheHandler
  private redis: CacheHandler

  constructor() {
    this.fs = new (require('./fs-handler').default)()
    this.redis = new (require('./redis-handler').default)()
  }

  async get(key: string) {
    // Try Redis first, fall back to FS
    const fromRedis = await this.redis.get(key)
    if (fromRedis) return fromRedis

    // FS fallback — warms Redis for next request
    const fromFs = await this.fs.get(key)
    if (fromFs) {
      await this.redis.set(key, fromFs)
    }
    return fromFs
  }

  async set(key: string, data: any) {
    // Write to both caches in parallel
    await Promise.all([
      this.fs.set(key, data),
      this.redis.set(key, data),
    ])
  }

  async has(key: string) {
    return this.redis.has(key) || this.fs.has(key)
  }

  async delete(key: string) {
    await Promise.all([
      this.fs.delete(key),
      this.redis.delete(key),
    ])
  }

  async revalidateTag(tag: string) {
    await Promise.all([
      this.fs.revalidateTag(tag),
      this.redis.revalidateTag(tag),
    ])
  }
}
Try it live
The Cold Cache Event
Switching to a new cache backend instantly makes every cached entry a miss. For a production system doing 10,000 req/s, that's 10,000 simultaneous origin requests. Stagger your migration over three TTL cycles to populate the new cache gradually.
Production Insight
A team ignored the phased migration and switched from FS to Redis handler in one deploy. The Redis cache was empty. Every request became a cache miss. The database received 8,000 qps — 10x normal load. Connection pool exhausted. Database CPU at 100%. Recovery required scaling database connections, restarting with FS handler, and re-attempting with the phased approach. Rule: never hot-switch cache backends. A cold cache event is a production incident waiting to happen.
Key Takeaway
Migrate cache backends in 3 phases: Phase 1 (write to new, read from old) → Phase 2 (read from new, write to both) → Phase 3 (remove old).
Each phase must run for at least one full cache expiry cycle of your longest cacheLife profile.
A cold cache event can cause a full production outage — never hot-switch cache backends.
● Production incidentPOST-MORTEMseverity: high

The Redis Cache That Slowed Everything Down — 200ms per Request

Symptom
Average response time increased from 45ms to 245ms after deploying the Redis cache handler. CPU usage dropped (because Redis did the work) but p95 latency spiked to 900ms. The team 'fixed' performance by adding Redis — and made everything slower.
Assumption
The team assumed that any external cache is faster than a filesystem cache. They believed Redis was inherently better because it's an 'industry standard' for caching, without considering network latency, serialization overhead, or the fact that their single-instance deployment never needed shared caching.
Root cause
Every HTTP request triggered 2-4 cache operations: check cache, read cache, write cache, check revalidation status. Each Redis operation required a network round trip averaging 8-15ms on their infrastructure. With 3-4 cache operations per request, the overhead was 24-60ms minimum. Under load (1,000 req/s), Redis CPU hit 70%, increasing P99 latency to 45ms per operation. The worst-case request required 4 Redis round trips = 180ms added overhead. The default filesystem cache, by contrast, completed all cache operations on local disk with 0.5-2ms latency per operation. The total overhead: 2-8ms per request. The external cache path was 23-90x slower than local FS cache. On top of that, the Redis cache handler serialized every value to JSON and back — adding another 5-15ms of CPU time per operation for large cached responses.
Fix
Removed the custom Redis cache handler and reverted to the default filesystem cache. For their single server instance, the FS cache was measurably faster. Created a cache handler abstraction that used local FS cache by default but falls back to Redis when 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.
Key lesson
  • 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-timing headers 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
Production debug guideDiagnose cache performance and invalidation issues in Next.js 164 entries
Symptom · 01
Response times increased after adding custom cache handler
Fix
Add 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 apps
Symptom · 02
Stale data persists after cache invalidation
Fix
Verify that revalidateTag() 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 it
Symptom · 03
cacheLife profiles not applying expected TTL
Fix
Check that your cacheLife profile is registered before any fetches use it. Profile registration order matters — profiles registered in a layout apply to all pages in that layout. For debugging, add TTL to response headers: x-cache-ttl: stale=60, revalidate=300
Symptom · 04
Memory usage grows unbounded in Redis cache
Fix
Configure Redis maxmemory-policy to 'allkeys-lru' or 'volatile-lru'. Without an eviction policy, Redis grows until it hits the system memory limit. For cache data, LRU eviction is the correct approach — never use 'noeviction' for caches
★ Custom Cache Handler Quick Debug ReferenceFast commands for diagnosing Next.js 16 cache handler issues
Cache miss rate is >50% in production
Immediate action
Check cache TTL configuration and hit rate metrics
Commands
curl -sI https://your-app.com/page | grep -i 'x-cache\|x-vercel-cache'
redis-cli --raw info stats | grep 'keyspace_hits\|keyspace_misses'
Fix now
Increase cacheLife stale and expire durations. Ensure cacheLife profiles match your data freshness requirements. Consider increasing the stale window to serve stale data during revalidation
revalidateTag does not invalidate cached pages+
Immediate action
Check that cacheTag is passed correctly and revalidateTag uses the same string
Commands
grep -rn 'revalidateTag\|cacheTag' app/ --include='*.tsx' --include='*.ts'
grep -rn "tags:" app/ --include='*.tsx' --include='*.ts' -A2 -B2
Fix now
Verify tag strings are identical. cacheTag(['products']) must match revalidateTag('products'). Check cache handler implements revalidateTag — custom handlers often miss this
High Redis latency (>20ms per operation)+
Immediate action
Check Redis server health and network latency
Commands
redis-cli --latency -h <host> -p <port>
redis-cli --raw info stats | grep 'instantaneous_ops_per_sec'
Fix now
Move Redis to same region/VPC as Next.js server. Reduce round trips by batching cache operations. Use Redis pipelining in custom cache handler. Consider Cloudflare KV for edge-cached data
⚙ Quick Reference
9 commands from this guide
FileCommand / CodePurpose
applayout.tsxcacheLife({cacheLife Profiles
appproductspage.tsxexport default async function ProductsPage() {cacheTag
cacheredis-handler.tsinterface CacheValue {Building a Custom Cache Handler
cache.tscacheLife({Stale-While-Revalidate
cachekv-handler.tsexport default class KVCacheHandler implements CacheHandler {Redis vs Memcached vs Cloudflare KV
appactionsproduct.ts'use server'Cache and Server Actions
cachededuplication.tsconst inflightRefreshes = new Map>()Cache Deduplication and Coalescing
libcache-metrics.ts'use server'Cache Monitoring
cachemigration-handler.tsexport default class MigrationHandler implements CacheHandler {Production Migration

Key takeaways

1
Default filesystem cache is 0.5-2ms per operation
faster than any remote cache for single-instance deployments; only add Redis for shared caching across 2+ instances
2
cacheLife profiles define stale, revalidate, and expire timestamps per data type
one source of truth replacing scattered revalidate values across fetch() calls
3
cacheTag enables on-demand cache invalidation via revalidateTag
mark any cached response as stale instantly when data changes
4
Stale-while-revalidate never blocks a user on cache rebuild
stale window must be 3-5x larger than revalidate to absorb traffic spikes
5
Migrate cache backends in 3 phases over 3 TTL cycles
a cold cache event can cause a production outage by flooding the origin
6
Cache monitoring requires hit rate (target >80%), latency (p99 <50ms), revalidation count, and eviction rate
measure before and after any cache change
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
You deploy a Next.js 16 app to 4 server instances behind a load balancer...
Q02SENIOR
Explain the stale-while-revalidate pattern as implemented in Next.js cac...
Q03SENIOR
You need to invalidate cached data across 10,000 product pages when a si...
Q04SENIOR
Next.js deduplicates cache revalidation requests within a single server ...
Q05SENIOR
Your cacheLife profile has stale: 600, revalidate: 600, expire: 3600. A ...
Q01 of 05SENIOR

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.

ANSWER
First, I'd verify that a shared cache is actually needed. I'd instrument each instance to report its cache hit rate individually. If the aggregate hit rate across all instances is 80%+ despite independent caches (meaning each instance has a high hit rate on its own traffic), a shared cache may not help much — users are already getting cached responses from whichever instance they hit. If hit rate is low per instance (say 40% each because traffic is evenly distributed and no instance sees the same URL twice), a shared cache will help. If shared caching is justified, I'd implement a cache handler class with Redis (using Upstash for serverless or ioredis for persistent servers). The handler implements get, set, has, delete, and revalidateTag. The key design decisions: serialization format (JSON for most data, but consider msgpack for large payloads), TTL defaults (match the app's cacheLife profiles), and tag-based invalidation (maintain a Redis SET mapping each tag to the cache keys it covers for efficient revalidationTag). For metrics, I'd measure: cache hit rate before and after, p50/p95/p99 latency per cache operation, per-page response time (with and without cache), and database/API origin request rate. I'd add 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.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
When should I use a custom cache handler instead of the default filesystem cache?
02
What is the difference between cacheLife and cacheTag?
03
Can I use cacheLife and cacheTag together on the same fetch?
04
Does stale-while-revalidate work with the default filesystem cache?
05
How can I measure current cache hit rate in production?
06
What happens if my Redis cache goes down?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
🔥

That's Next.js. Mark it forged?

8 min read · try the examples if you haven't

Previous
Bundle Analysis and Performance Optimization in Next.js 16
46 / 56 · Next.js
Next
MDX, Content Management, and Draft Mode in Next.js