Refresh twice and it comes back: the Next.js cache that isn't shared between your pods
A discontinued article disappeared from the catalogue for one colleague and stayed visible for the next, because the cache Vercel runs for you is a per-process detail the moment you self-host. What revalidateTag actually reaches, what a shared cache handler costs you in exchange, and why our own answer is still one replica.
At 09:12 a purchaser at a technical wholesaler archived an article in Odoo, because the manufacturer had discontinued it and the last four were already allocated. The webhook fired, the storefront revalidated, and the article vanished from the catalogue. We know it vanished because she refreshed the page and watched it go.
At 09:40 her sales manager sent a screenshot of the same article, still listed, still orderable. She reloaded his link on her own machine: gone. He reloaded on his: there. They did this four more times, on speakerphone, getting different answers in the same minute, and then someone wrote the ticket that eventually reached us. The subject line was "site is showing deleted products again (haunted?)".
It wasn't haunted. The storefront was running three replicas behind an ingress, and each one of them had its own private copy of that page.
The cache stopped being a platform and became a detail
Everybody knows Next.js caches rendered output. Far fewer people know where, because on a managed platform the answer is "somewhere shared, globally, atomically, not your problem." That is a genuine product, and it is one of the things you are actually paying for. The framework doesn't come with it.
Self-hosted, the default is per process. Next 16's use cache entries live in an in-memory LRU inside the Node process that rendered them; the prerender cache sits in that process's memory and on its local disk. The docs are direct about it: the default in-memory cache "is isolated to each Next.js process," and if you're running a container orchestrator, "each pod will have a copy of the cache."
That sentence is easy to read as a performance note. It isn't. It's a correctness note, because a cache with a revalidation window is not just making things faster — it's the thing deciding what your visitors are allowed to see, and you now have one of those per replica, with no relationship between them.
This is the part of self-hosting that nobody's runbook covers. We've written before about choosing static over a request-time server, and that argument holds: most pages should be prerendered and most "it has to be live" requirements really mean "within a minute." But the moment a page opts into a revalidation window and you run more than one of it, you've quietly signed up for a distributed systems problem, and the only symptom is a colleague seeing something you don't.
Three pods, three answers, and a 1-in-3 chance of believing your own fix
The arithmetic is unkind and worth stating plainly. With n replicas behind a round-robin ingress, a single content change that lands on one pod leaves n-1 pods serving the old page. Your own verification refresh has a 1/n chance of hitting the pod that knows. At three replicas that's a coin flip you win often enough to file the bug as fixed.
Time-based revalidation doesn't rescue you either, and this is the bit that surprises people who assume a short window makes the problem self-healing. A 60-second window is 60 seconds per pod, measured from when that pod last rendered the page — three independent clocks started at three different moments. Convergence eventually happens, but "eventually" is not a property the sales manager experiences. What he experiences is a page that changes when he refreshes, which is worse than a page that's simply out of date, because a consistently stale page is something you can explain to a customer.
It also poisons your own debugging. Three pods with different content is indistinguishable, from the outside, from an intermittent bug in your code. We have watched a team spend most of a day looking for a race in a data loader that was rendering perfectly correctly, three times, from three different inputs.
revalidateTag reaches the pod that answered the webhook, and nothing else
Here is the code that causes this, and it is not wrong-looking code. This is the shape of every ERP-to-site webhook we've ever built:
import { revalidateTag } from "next/cache"
export async function POST(request: Request) {
const { sku, secret } = await request.json()
if (secret !== process.env.ODOO_WEBHOOK_SECRET) {
return new Response("nope", { status: 401 })
}
revalidateTag(`product:${sku}`)
revalidateTag("catalogue")
return Response.json({ ok: true })
}The webhook is an HTTP request. It goes through the same ingress as everything else, and it lands on exactly one pod. revalidateTag() invalidates that pod's tag state. The other two never hear about it — not because of a bug or a race, but because nothing in the default setup gives them a channel to hear it on. The docs put it in one sentence: calling revalidateTag() on one instance "only invalidates the cache on that instance."
revalidatePath() is not a way around this. It's the same mechanism with different tags: Next generates implicit soft tags from the route path — internally prefixed _N_T_ — and revalidatePath("/catalogue") invalidates through those. Same tag system, same single pod.
So the honest description of that route handler is: a webhook that fixes one third of your infrastructure, chosen at random by a load balancer. And it will pass every test you write for it, because in next dev and in your integration environment there is only ever one instance.
The three weeks our own site served deploy-time data
Before prescribing anything, the version of this we got wrong ourselves, because it's a different failure with the same signature and it cost us longer to find.
This site's pods run with a read-only root filesystem. That's a deliberate hardening choice and we'd make it again. What we hadn't thought through is that ISR's default behaviour is to flush regenerated pages to disk — and on a read-only filesystem, that write fails with EROFS. Not loudly. The page renders, the response is served, the cache write fails somewhere in the background, and the next request gets the build-time page again. Forever.
/jobs has a 60-second window because a role closing shouldn't need a deploy. For a while it was serving whatever was true when the image was built, and nothing anywhere said so. No 500, no error in the logs we were watching, no failed health check. The only way to notice is to compare what the ERP says with what the page says, which is exactly the check nobody runs on a page that has always worked.
The fix is one line and a comment longer than the line:
experimental: {
// The cluster pods run with a read-only root filesystem, so ISR's default
// flush-to-disk fails with EROFS on every revalidation and pages stay
// frozen at their build-time content. Keep the ISR cache in memory
// instead — single-replica deploys, so no cross-instance sharing to worry
// about; a restart just serves the build-time page until the first
// revalidation.
isrFlushToDisk: false,
},Two things generalise from that. A failed cache write does not look like a failure. Next deliberately degrades gracefully here — a set() that fails still serves the response to the user, and the framework treats a cache miss as a normal outcome. That's the right design and it means silence is not evidence. And the mitigation only holds because of an assumption written down next to it: single replica. The day somebody scales that Deployment to two for an unrelated reason, the comment stops being true and the wholesaler's bug arrives here.
That's the actual lesson. The caching behaviour of a self-hosted Next.js app is a function of your replica count, your filesystem, and your deploy strategy — three things owned by people who have never read your next.config.ts.
Option one: don't have the problem
The cheapest fix for cross-pod cache incoherence is to not run pods, plural.
Run one replica. For a marketing site, a brochure site, or an internal tool with forty users, a single Next.js pod is not the embarrassment it sounds like. output: "standalone" starts in well under a second, a prerendered page costs almost nothing to serve, and right-sizing that pod is a much smaller job than operating a distributed cache. You give up surviving a node failure without a restart, and you need maxSurge/maxUnavailable and a real readiness probe so rollouts don't drop requests — which you wanted anyway. This is what this site runs, and it is not a temporary state we're apologising for.
Or rebuild instead of revalidating. If content lives in the repo — MDX, a config file, a site with no CMS at all — then "publishing" is a commit and a deploy, every pod gets identical output because they're running the same image, and the cache question never arises. Slower to publish, free to operate, impossible to get into a split-brain state. For a blog, this trade is not close.
Both of these are unglamorous and both are correct far more often than the architecture diagrams suggest. Reach for them first, and be suspicious of any replica count that nobody can justify with a traffic number.
Option two: a shared cache, and an honest look at the bill
Sometimes the replicas are load-bearing — real traffic, a rolling deploy you can't have a gap in, an SLA. Then you need the instances to agree, which means the cache moves out of the process.
In Next 16 there are two doors here and they're easy to confuse. cacheHandler (singular) is the older server cache handler, still the mechanism behind Pages Router on-demand ISR. cacheHandlers (plural, new in 16.0) configures storage for the use cache family, and it's the one you want on a modern App Router app:
const nextConfig: NextConfig = {
cacheComponents: true,
cacheHandlers: {
remote: require.resolve("./cache-handlers/dragonfly.js"),
},
}With that wired up, 'use cache: remote' marks the specific things that should live in shared, durable storage rather than in one pod's memory — while plain use cache stays in-process for the things that don't need it. That granularity is the good news: you do not have to move your whole render pipeline onto Redis to fix one page.
The bad news is the handler contract, and it has two rules that are not suggestions:
module.exports = {
async get(cacheKey, softTags) {
try {
return await readEntry(cacheKey)
} catch (error) {
// A throw here is NOT a cache miss — the framework does not wrap get()
// in a try/catch, so it propagates as a render error. Return undefined.
report(error)
return undefined
}
},
async refreshTags() {
try {
await syncTagTimestampsFromShared()
} catch (error) {
// Called before every request. An uncaught throw here fails the REQUEST.
// Catching it serves possibly-stale content instead of an error page.
report(error)
}
},
async updateTags(tags) {
// revalidateTag() lands here. Write the invalidation somewhere every
// other instance will read it — this is the part that crosses the pod
// boundary, and the only part that fixes the webhook above.
await publishInvalidation(tags, Date.now())
},
}updateTags() is where the webhook's invalidation becomes visible to instances that never received it, and refreshTags() — called before each request — is where they pick it up. Miss either one and you've built a shared cache that still doesn't propagate invalidations, which is the worst of both worlds: new infrastructure, same bug.
Now the bill, stated plainly, because this is the decision people make too fast:
Your rendering path now depends on a database. Before, a page render touched memory and disk. Now every cached lookup is a network round trip, and refreshTags() runs before every request. If the key-value store is slow, your site is slow; if it's down and you didn't catch, your site is down. Fail open — return undefined, serve stale, log it — the same principle we apply to every other optional dependency in a request path.
You are operating a new stateful service. Backups, memory limits, eviction policy, an alert when it's unreachable, a story for what happens during its own upgrade. If you already run Redis or Dragonfly in the cluster for something else, this is marginal. If this is the first one, you are adding a stateful dependency to a stateless app to fix a page that changes twice a day, and that trade deserves five minutes of thought rather than an afternoon of implementation.
It does not survive a deploy. Remote cache entries are keyed by deploymentId (or the build ID), so a new build produces new keys and yesterday's entries are unreachable. This is intentional — a refactored cached function can change the shape of its own return value, and reusing entries across builds risks serving something malformed. It also means the shared cache buys you cross-pod agreement, not a warm cache after a rollout. Every deploy is still cold. Plan capacity for the cold minute, not the warm hour.
Somebody has to maintain it. The published Redis handler is an example, not a product. Serialising a ReadableStream into a key-value store, deciding what to do with a partial write, atomic replace so a half-written entry never gets read — that's real code in your repo with no tests written by anyone but you.
If after all that the answer is still yes, do it. It's a legitimate piece of engineering and sometimes it's the only thing that meets the requirement. Just don't reach for it because a page was briefly stale.
Option three: take the decision out of the cache
The most under-used answer is to notice that not everything on the page needs the same freshness, and the thing that must be correct is usually one small fact.
A catalogue page is a heavy render: layout, navigation, images, description, specifications, related articles. Almost all of it can be a day stale with no consequence. The single fact that caused the incident — is this article still sellable — is one boolean. Caching them together at the same freshness, as one unit, is what created the problem.
Split them. Keep the expensive shell cached and let the one volatile fact be resolved at request time inside a Suspense boundary — that is precisely what Cache Components and partial prerendering exist for, and the pattern we already use for ERP-backed pages. The shell is identical on all three pods because it came out of the same build. The boolean is read live, so all three pods agree by construction. There is no cache to keep coherent.
The second half of this is the one people forget: enforce it at the write, not just the read. However fresh the page is, somebody will eventually submit against a version of it that is no longer true — a cached page guarantees it. So the server action re-checks before it acts:
export async function addToQuote(sku: string, quantity: number) {
const article = await readArticleLive(sku)
if (!article?.sellable) {
return { ok: false, reason: "discontinued" as const }
}
return createQuoteLine(article.id, quantity)
}This site does the same thing for job applications: the page can be up to a minute stale, so the submit action re-reads whether the role is still open before it writes anything into the ERP. Six lines, no new infrastructure, and it converts "the cache was wrong" from a data-integrity incident into a polite message on a form.
It's worth being blunt about the reframe. A stale page is a UX problem, and a 60-second window is usually an acceptable answer to it. A stale decision is a business problem, and no cache window is an acceptable answer to that. Sort your requirements into those two piles before you sort out your infrastructure, because the second pile is almost always much smaller than it first appears — and everything in it should be read live regardless of what the cache is doing.
The same bug, wearing a deploy as a disguise
While you're counting replicas, there are two multi-instance failures adjacent to this one that produce even stranger tickets — and unlike the cache, these bite during a rolling deploy even if you normally run one pod.
Server Actions stop being recognised. Next encrypts server function arguments with a key generated per build. Two instances from different builds, or a client that loaded deploy A talking to a pod running deploy B, and you get Failed to find Server Action — a form that simply doesn't submit, for some users, for the duration of a rollout. Pin NEXT_SERVER_ACTIONS_ENCRYPTION_KEY so every instance shares one, and make sure all containers run the same build rather than each rebuilding from source. If your pipeline rebuilds per environment, generateBuildId from the git SHA.
Prefetched pages come from the wrong version. Set deploymentId. Next then stamps assets with ?dpl= and sends an x-deployment-id header on navigations; when the server sees a mismatch it forces a hard navigation instead of stitching together two builds. Cheap insurance against the class of bug that only exists for ninety seconds during a deploy and is therefore never reproducible when you go looking.
Both of these are one config line each. Both are invisible until the day a client's marketing team is doing a launch during your deploy window.
What we ship by default
- One replica, prerendered pages, and a rebuild to publish. Reviewed against actual traffic, not against a feeling about scale. Most business sites never leave this box.
- A freshness number per volatile thing, written down.
cacheLifeprofiles on the reads, not blanket route config —{ stale: 60, revalidate: 60, expire: 86400 }says what we promise far better than a sentence in a handover document does. - Every mutation re-validates against the source. The page is a hint. The ERP decides.
deploymentIdand a pinned Server Actions key on anything with more than one instance. Before it's needed, not after the launch-day ticket.- A check that compares the source of truth to the rendered page. Ours would have caught three weeks of frozen
/jobson day one; it belongs in the same place as the rest of your monitoring, because the alternative is finding out from the client. - A shared cache handler only when replica count is justified by traffic — and then with fail-open
get(), a caughtrefreshTags(), and an alert on the store itself.
Where we push back
When a client asks for "instant" we ask what number instant is, because it's never zero — even on a managed platform there is propagation, and a CDN in front of you has its own opinion. Naming the number (five seconds? a minute? next morning?) usually collapses the whole problem, and quite often the honest answer is that a manual "refresh catalogue" button an office manager can press beats a webhook nobody will maintain.
We also push back on replica counts nobody can defend. "Three, for high availability" on a site doing forty requests a minute is how a stateless application acquires a distributed cache problem and then a Redis dependency to solve it, in service of an availability target that was never written down. Two replicas of a thing that should be one is not resilience — it's a second source of truth with a load balancer choosing between them.
And we push back on treating this as a Next.js flaw. It isn't. The framework's defaults are correct for one process and it documents exactly what changes when there's more than one. What went wrong at the wholesaler was that a platform capability quietly became a self-hosting responsibility during a migration, and nobody was handed the list.
The short version
A cache with a revalidation window is a small piece of state that decides what your visitors see. Self-hosted, you get one of those per pod, and nothing connects them — so revalidateTag() fixes the instance that received the webhook, a 60-second window is n independent 60-second windows, and the symptom is two colleagues on a call getting different answers about the same page.
Three ways out, in the order we try them: run one replica and rebuild to publish; take the fact that must be correct out of the cache entirely and read it live inside a Suspense boundary; or move the cache into shared storage and accept that your render path now depends on a key-value store that needs backups, alerts and a fail-open handler. The third one is real engineering, not a config line, and it still doesn't give you a warm cache after a deploy.
Whichever you pick, re-check at the write. The page is allowed to be a minute behind; the order that gets placed against it is not.
This is the shape of nearly every self-hosted Next.js app we run in front of an ERP that keeps changing underneath it. If your site has ever been described as haunted, count your replicas first.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.