Self-Hosted ISR with a Shared Redis Cache Handler

For teams running Next.js ISR Implementation outside Vercel, this guide replaces the per-instance filesystem cache with a shared Redis cache handler, so a CMS webhook that reaches one container revalidates the page for every container behind the load balancer.

On a single server, ISR just works: rendered pages and fetch results live in .next/cache on local disk, and revalidateTag updates that disk. Scale to three containers on ECS, Kubernetes or Fly.io and each one has its own disk. The webhook lands on one of them, that container marks its entries stale, and the other two keep serving the old page until their own windows expire. Readers get different versions depending on which container answers.

Per-instance caches versus one shared cacheWith local caches, a webhook routed to instance A leaves instances B and C stale; with a shared Redis handler, all instances read the same entries and one revalidateTag call reaches all of them.Load balancerInstance ArevalidatedInstance Bstill staleInstance Cstill staleRedisshared entries + tag indexwebhookcacheHandlercacheHandlercacheHandler
The load balancer decides which instance receives the webhook; a shared cache makes that choice irrelevant.

The Problem

Cache drift across instances shows up as intermittent staleness, which is the hardest kind to debug. An editor publishes, checks the page, sees the update and moves on. A customer reports the old price an hour later. Reloading shows the new price, then the old one, then the new one again, as the load balancer rotates between containers. Logs show the webhook succeeded, because it did, on one container.

There is a second, quieter cost. Every container regenerates every page independently, so CMS API usage scales with the number of instances. Autoscaling from three to twelve containers during a traffic spike quadruples regenerations at exactly the moment the CMS is also under load.

How the Cache Handler Works

Next.js lets you replace its incremental cache with a custom class through the cacheHandler option in next.config. The class implements get(key), set(key, data, ctx) and revalidateTag(tags). Next.js calls get before serving a cached page or fetch result, set after rendering or fetching, and revalidateTag when your webhook route calls the function of the same name. Setting cacheMaxMemorySize: 0 disables the default in-memory layer, so every read goes to the shared store and no instance can hold a stale copy in memory.

Tags need an index. Redis cannot query “every key with tag X”, so the handler maintains a set per tag (tag:article:7Ht2 → {key1, key2}) and a timestamp per tag recording when it was last revalidated. On get, the handler compares the entry’s lastModified with the revalidation time of each of its tags. If any tag was revalidated after the entry was written, the entry is stale.

get, set and revalidateTag against RedisA render stores an entry with its tags, a webhook records a revalidation timestamp for a tag, and a later get on another instance compares timestamps and reports the entry stale.Instance ARedisInstance BSET entry + SADD tag setsrevalidateTag: HSET tag time nowGET entry + tag timestag newer than entrytreat as staleSET regenerated entry
Revalidation writes one timestamp; staleness is computed on read, so no instance needs to be notified.

Implementation

The handler below uses ioredis and stores entries as JSON. Buffers in route bodies are base64-encoded by Next.js already, so plain JSON serialization is safe for pages, route handlers and fetch results.

TypeScript
// cache-handler.ts (compile to cache-handler.mjs, or write it as .mjs directly)
import Redis from "ioredis";

interface CacheEntry {
  value: unknown;
  lastModified: number;
  tags: string[];
}

interface SetContext {
  tags?: string[];
  revalidate?: number | false;
}

const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
  keyPrefix: `isr:${process.env.BUILD_ID ?? "dev"}:`, // new build, new namespace
  maxRetriesPerRequest: 2,
});

const TAG_TIMES = "tag-times"; // hash: tag -> last revalidation (ms)

export default class RedisCacheHandler {
  constructor(private readonly options: Record<string, unknown>) {}

  async get(key: string): Promise<CacheEntry | null> {
    const raw = await redis.get(`entry:${key}`);
    if (!raw) return null;
    const entry = JSON.parse(raw) as CacheEntry;
    if (entry.tags.length) {
      const times = await redis.hmget(TAG_TIMES, ...entry.tags);
      const newest = Math.max(0, ...times.map((t) => Number(t ?? 0)));
      if (newest > entry.lastModified) return null; // a tag was revalidated after this entry was written
    }
    return entry;
  }

  async set(key: string, value: unknown, ctx: SetContext): Promise<void> {
    const tags = ctx.tags ?? [];
    const entry: CacheEntry = { value, lastModified: Date.now(), tags };
    const pipeline = redis.pipeline();
    // Keep entries past their window so stale-while-revalidate can still serve them.
    const ttl = typeof ctx.revalidate === "number" ? ctx.revalidate * 10 : 60 * 60 * 24 * 30;
    pipeline.set(`entry:${key}`, JSON.stringify(entry), "EX", ttl);
    for (const tag of tags) pipeline.sadd(`tag:${tag}`, key);
    await pipeline.exec();
  }

  async revalidateTag(tags: string | string[]): Promise<void> {
    const list = Array.isArray(tags) ? tags : [tags];
    const now = Date.now();
    await redis.hset(TAG_TIMES, Object.fromEntries(list.map((t) => [t, String(now)])));
  }

  resetRequestCache(): void {
    // Per-request memoization is handled by Next.js; nothing to reset here.
  }
}
JavaScript
// next.config.mjs
export default {
  cacheHandler: new URL("./cache-handler.mjs", import.meta.url).pathname,
  cacheMaxMemorySize: 0, // no per-instance memory layer that could drift
  generateBuildId: async () => process.env.BUILD_ID ?? `${Date.now()}`,
};

Returning null from get for an entry with a revalidated tag makes Next.js treat it as a miss, render fresh and call set. For pages this means the first visitor after a publish waits for a render instead of receiving the stale version. If you prefer strict stale-while-revalidate, return the entry together with a flag that marks it stale. Newer Next.js versions let the handler signal this. Check the handler interface for your version, because it has changed across major releases.

Configuration Reference

Setting Recommended Why
cacheHandler absolute path to the compiled handler Loaded once per server process.
cacheMaxMemorySize 0 Removes the in-memory LRU that would otherwise drift per instance.
Redis key prefix includes the build id A deploy gets a clean namespace; old entries expire on their own.
Entry TTL 10x the window, or 30 days Long enough to serve stale, short enough to bound memory.
Redis eviction policy volatile-lru Evicts expiring entries first, never the tag index hash.
Redis deployment same region as the app, replicated Every page view now includes a Redis round trip.

Gotchas & Edge Cases

  • Build id mismatch during rolling deploys. Old and new containers run side by side for a few minutes. With the build id in the key prefix, they use separate namespaces, which is correct because their render output differs, but a webhook during the rollout only revalidates the namespace of the container that received it. Record tag times in an unprefixed hash so both builds see them.
  • Redis outages. If get throws, Next.js treats it as a miss and renders every request dynamically, which can overload the CMS. Wrap Redis calls in a short timeout and fall back to a small local LRU while Redis is unavailable.
  • Large pages. A page with a big HTML body and RSC payload can exceed a megabyte. Compress values with Brotli before storing, which typically shrinks HTML by 80 percent or more.
  • Tag sets grow forever. SADD never removes keys from tag sets. Run a periodic job that removes members whose entry no longer exists, or store tag sets with the same TTL as their entries.
  • Image optimization cache. next/image keeps its own cache on disk, outside the handler. Put a CDN in front of /_next/image, or accept per-instance image caches, which are harmless because images are immutable per URL.

Rolling the Handler Out

Switching cache backends on a live site empties the cache, because Redis starts cold. The first request for every page renders on demand, and on a large site that can mean a burst of CMS requests. Warm the cache before shifting traffic: deploy the new build to a single instance, run a crawler over your sitemap against that instance at a modest rate, then roll the remaining instances. Because the key prefix includes the build id, the warmed entries are exactly the ones the new build will read.

Keep an eye on Redis latency during the first days. Every cached page view now costs one or two Redis round trips: the entry, plus the tag times. That is well under a millisecond in the same availability zone and noticeably slower across regions. If your application runs in several regions, give each region its own Redis replica set and fan out tag revalidations to all of them from the webhook route.

Verifying the Result

Run three instances locally with Docker Compose behind a round-robin proxy, publish a change in the CMS, and request the page ten times with curl. Every response after the webhook should carry the new content. Before the handler, roughly two thirds of them would have been stale. In production, graph the ratio of get misses to hits in Redis alongside CMS request counts: after the switch, CMS requests should stop scaling with instance count. When the bytes look right but readers still see old pages, the stale-page debugging guide helps separate the Next.js cache from the CDN in front of it.

CMS requests per hour as instances scaleRegeneration requests to the CMS per hour for 500 active routes with a 300-second window, with local caches at 3 and 12 instances and with a shared cache.Local cache, 3 instances18000 req/hLocal cache, 12 instances72000 req/hShared Redis, any count6000 req/h500 routes x 12 regenerations per hour per cache copy.
Local caches multiply CMS load by the instance count; a shared cache keeps it flat.

Frequently Asked Questions

Is there a maintained library instead of writing a handler?

Community packages implement Redis handlers with tag support and sensible defaults, and they track changes in the Next.js cache interface across versions. They are a good choice if you do not need custom behaviour. The implementation above is small enough to own if you prefer no extra dependency.

Does this work with the “use cache” directive?

The "use cache" directive in newer Next.js versions uses a separate cacheHandlers configuration with a different interface. The same principles apply: shared storage, tags indexed by revalidation time and no per-instance memory layer. The method names and signatures differ, though, so follow the interface documented for your version.

Can I use a CDN instead of Redis to share the cache?

A CDN in front of all instances hides drift for full-page HTML, but it does not share the fetch cache. Instances still regenerate independently, and CMS load still scales with instance count. Use both: the shared handler for correctness inside the application, and the CDN for latency.