COLUMN
Self-Hosted Next.js ISR: Why On-Demand Revalidation Breaks Across Multiple Instances

Next.js runs the same next build && next start whether you're on Vercel or on your own servers — until you turn on Incremental Static Regeneration. That's when the platform underneath you stops being invisible.
What Vercel Is Actually Doing When You Call revalidatePath
On Vercel, ISR feels like magic: you call revalidatePath("/products/123") from a route handler, and within seconds every visitor gets the fresh page. What's easy to miss is that Vercel's infrastructure is doing real work behind that call — it invalidates the cached page across its entire edge network in one coordinated step, because Vercel controls both the compute and the cache layer as a single system.
Self-hosted Next.js doesn't have that layer. When you run next start (or the output: "standalone" build) yourself, the default cache handler writes ISR pages and fetch results to the local filesystem, under .next/cache, scoped to that one process. That distinction — a single shared cache versus one cache per process — is the entire story of why ISR "breaks" after a migration off Vercel.
The Bug: One Instance Revalidates, the Others Don't Know
Almost nobody runs next start as a single process in production. You run two, four, or a dozen replicas behind a load balancer or Kubernetes Service, for redundancy and to handle load. Each replica boots its own copy of the app, and each one builds its own independent filesystem cache the first time a page is requested.
Here's the sequence that catches teams off guard:
- A request for
/products/123hits replica A. Replica A renders the page and writes it to its own.next/cache. - An editor updates the product and your CMS webhook calls
revalidatePath("/products/123"). That request also lands on replica A (or whichever replica the load balancer happens to route it to). Replica A clears its cache entry. - The next few customer requests get load-balanced to replicas B, C, and D — none of which ever received the revalidation call. They keep serving the stale page from their own cache until their independent revalidation window expires.
Nothing crashes, nothing logs an error. The page is simply wrong for some fraction of visitors, for a duration that depends on your load balancer's routing and each replica's own cache timing. That inconsistency, more than the staleness itself, is what erodes trust in the CMS.
// route handler used by the CMS webhook
import { revalidatePath } from "next/cache";
export async function POST(request) {
const { path } = await request.json();
revalidatePath(path);
return Response.json({ revalidated: true, now: Date.now() });
}
That code is correct. It works exactly as documented. It just only ever touches the process that executes it — which is fine on Vercel, where "the process" is an abstraction over a shared cache, and a real limitation the moment you own the servers.

Fixing It: Give Every Instance the Same Cache
The underlying fix is always the same shape: stop letting each replica own its cache, and point every instance at one shared store instead. Next.js supports this directly through the cacheHandler option in next.config.js, which lets you replace the default filesystem cache with a custom implementation:
// next.config.js
module.exports = {
cacheHandler: require.resolve("./cache-handler.js"),
cacheMaxMemorySize: 0, // disable the default in-memory layer so every read hits the shared handler
};
cache-handler.js implements get, set, and revalidateTag against a backend every replica can reach — Redis is the common choice, since it already supports the tag-based invalidation Next.js expects and handles concurrent writes from multiple instances without extra coordination. Once the cache handler is shared, a revalidatePath call executed on any single replica updates the one store that all replicas read from, and the "which instance answered the webhook" problem disappears.
A few teams reach for sticky sessions on the load balancer instead, routing a given client to the same replica every time. It's worth naming why that's a workaround and not a fix: it doesn't help other users hitting other replicas, it complicates autoscaling because replicas can no longer be added or removed freely, and it does nothing for revalidation triggered by a webhook rather than a user request. Sticky sessions mask the symptom for one request path while leaving the actual cache inconsistency in place.
Once the Cache Is Shared, Deployment Consistency Is the Next Problem
A shared cache handler solves correctness, but it introduces an operational question: every replica now depends on the same external service being configured identically, and every deploy has to roll out to all replicas without leaving an old build reading from a cache shaped for a newer one. This is where the self-hosting workload shifts from "Next.js configuration" to "how you run infrastructure."
Teams that get this right tend to already have declarative, GitOps-based deployments in place — the cluster state (including the cache backend's connection details) is defined once and applied consistently everywhere, rather than hand-configured per replica. If you haven't set that up yet, this walkthrough of ArgoCD covers the practical steps for a Kubernetes-based deployment. Pairing that with a CI/CD pipeline that redeploys every replica from the same build artifact — GitHub Actions is a common and well-documented choice — closes the loop: the cache handler is shared, the config that points to it is shared, and every replica runs the exact same build.

None of this is free, and it's worth naming the tradeoff plainly: you're taking on Redis (or an equivalent shared store), a deployment pipeline, and the operational load of keeping multiple replicas healthy — work that Vercel used to absorb for you. For a small side project, that might genuinely not be worth it. For a team already running Kubernetes for other services, the incremental cost of adding a shared cache and wiring it into an existing GitOps pipeline is usually a day of work, not a redesign.
Where This Leaves the Decision
If you're evaluating self-hosting because Vercel's bill outgrew the budget, ISR is one of the concrete line items behind that number — the compute and cache infrastructure Vercel runs for you doesn't disappear when you leave, it just becomes something your own cluster has to run. A breakdown of where Kubernetes costs actually go across compute, storage, and networking is a useful next read before committing to a self-hosted setup, since the cache backend and extra replicas are exactly the kind of cost that's easy to underestimate up front.
This is also the point where a managed Kubernetes platform earns its keep: the value isn't running Kubernetes instead of Vercel, it's not having to independently solve ingress, TLS, autoscaling, and GitOps deployment before your shared-cache fix can even ship. Kubo runs standard Kubernetes with that plumbing already wired up, so a Redis-backed cache handler and a GitOps pipeline are things you configure, not things you build from scratch.
If you're at the point of comparing what self-hosting actually costs against what Vercel charges, Kubo's plans start at the Ashigaru tier at ¥8,800/month for individual developers and small projects, with most small teams landing on the Ronin tier at ¥17,600/month. Neither number includes the engineering time saved by not hand-rolling ingress and autoscaling — but it's a concrete starting point for the comparison.