Skip to main content
September 9, 2026/4 min read

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.

zanaen
Muhammad Zanaen Ullah
Senior Engineer
How to Scale Next.js App Router in Production

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.

Share