How to Scale Next.js App Router in Production
What actually breaks when Next.js App Router moves from next dev to a real production deployment — caching, on-demand revalidation, multi-process pitfalls, and streaming.

What does it actually take to run Next.js App Router in production at real traffic? Two things, mostly: understanding exactly what the Full Route Cache is caching, and knowing that revalidatePath() only invalidates the process that runs it — not every process behind your load balancer.
The Full Route Cache is not your data cache
If you're not using fetch() with Next's cache options, App Router still caches something: the rendered output of your route, based on export const revalidate. That's true even for pages that hit a database directly through an ORM like Mongoose or Prisma — Next has no visibility into your database calls, it only knows how long to keep the HTML it already rendered.
// This still gets cached for an hour, even though getServices()
// hits MongoDB directly with no fetch() involved.
export const revalidate = 3600;
export default async function Page() {
const services = await getServices();
return <Services items={services} />;
}That's the right default for a marketing page that changes a few times a week. It's the wrong default the moment an admin expects an edit to show up on the next request.
On-demand revalidation beats a shorter time window
The instinct when content feels stale is to lower the revalidate window — 3600 to 300, maybe 60. That trades one problem for a worse one: now every visitor before the next revalidation still sees stale content, and you're re-rendering far more often for no reason. The actual fix is calling revalidatePath() at the exact moment content changes, from the mutation itself.
export async function updateService(id: string, data: ServiceInput) {
await ServiceModel.findByIdAndUpdate(id, { $set: data });
revalidatePath("/"); // fires immediately, not on a timer
}Keep the time-based revalidate as a fallback, not the primary mechanism — it catches anything that mutates data outside your own API (a direct database edit, a migration script), while on-demand revalidation handles the common case instantly.
The part that actually breaks in production: more than one process
This is the failure mode that never shows up in development, because next dev only ever runs one process. The moment you deploy behind a process manager that runs more than one Node instance — PM2 in cluster mode, Passenger with multiple worker instances, several container replicas — revalidatePath() only invalidates the route cache of the process that handled the request that called it.
A visitor whose request lands on a different process than the one that ran your revalidation will keep seeing the old cached HTML until that process's own timer-based revalidate eventually fires.
The same trap applies to anything you track in module-scope memory as a stand-in for "has content changed" — a counter, a timestamp, a flag. It works flawlessly with one process and fails silently, inconsistently, and confusingly the moment there's more than one.
// Breaks silently with >1 process — each process has its own copy.
let contentVersion = 0;
export function bump() { contentVersion++; }
// Works with any number of processes — one shared source of truth.
export async function bump() {
await db.collection("meta").updateOne(
{ _id: "content-version" },
{ $set: { touchedAt: new Date() } },
{ upsert: true },
);
}If you need cross-process coordination — a shared cache handler, a live-content-version check, rate limiting — the state has to live somewhere every process can reach: your database, or something like Redis. Not the process's own heap.
Streaming and Suspense change the buffering story
App Router's streaming responses only reach the browser as a stream if nothing between your Node process and the visitor buffers the whole response before forwarding it. A reverse proxy sitting in front of Next — nginx, Apache with mod_proxy, a CDN — can silently turn a streamed response back into a single blocking one if it isn't configured to pass chunks through.
Confirm your reverse proxy doesn't buffer proxied responses by default (nginx: proxy_buffering off for the relevant location).
If a CDN sits in front, check whether it waits for the full response before caching/forwarding — some do by design.
Test with curl --no-buffer against the real production path, not just next dev, since dev's behavior can mask a proxy misconfiguration entirely.
What we've actually run into
Most of this reads as reasonable in the abstract until you hit it on a real deployment. Building a poll-and-refresh mechanism so an admin's already-open browser tab picks up a content edit without a manual reload is a small feature — but the first version, backed by an in-memory counter, worked perfectly in development and then quietly failed the moment it ran behind a host that spun up more than one process. Moving that single counter into the database it already talked to fixed it completely, and cost one extra round-trip per poll. That's the trade you're almost always making: a little more latency for correctness that holds up under real infrastructure, not just a dev server.