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.
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.
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.
// 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.
}
}
// 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
getthrows, 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.
SADDnever 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/imagekeeps 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.
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.