The numbers
Most numbers in this post are illustrative. Three are measured in production:Step 1: Find out what the request waits for
Start with one slow request, not with the whole system. Trace where its time goes. In our case, most of it was waiting for the backend. A typical page (illustrative numbers) takes about 250 ms on the server: about 180 ms waiting for three backend calls, and about 50 ms rendering the React tree. So more pods would not help. Every new pod sends more calls to the same backend. The backend slows down, and then every page slows down with it. The fix is to cache the backend’s answers in Next.js. In the App Router, wrap the call inunstable_cache. It works with any client: axios, a GraphQL client or an SDK, not only fetch.
'use cache' directive does the same job.
This is the key idea of the whole post. Without a cache, cost grows with visitors. With a cache, cost grows with the number of different things you cache. A sale page has a few keys and many visitors, so it is a good fit.
After this step, the pod’s CPU is the limit: it still renders React for every visit.
In production we used an LRU cache in memory first. It works, but it is a second cache with its own TTL and its own size limit, next to the one Next.js already has.
unstable_cache keeps one cache and one TTL, and ISR in step 3 uses the same one.Step 2: Make the HTML the same for everyone
When we read the access logs, most traffic came from campaign links like these:- The session cookie, to render the header for a logged-in user.
- The
User-Agent, to choose a mobile or desktop layout.
- Reserve space for the account menu. The browser fills it in after load. If the avatar pushes the header wider or taller, you have added layout shift.
- Analytics still gets the campaign params. Dropping them from a cache key does not remove them from the address bar. Your analytics script reads them from
window.locationas before.
Step 3: Render once, serve many times (ISR)
With server-side rendering (SSR), Next.js renders the page for every request. With Incremental Static Regeneration (ISR), it renders once, saves the HTML, and serves the saved copy. After therevalidate time, the next visitor still gets the old copy, and Next.js builds a new one in the background. Nobody waits for a render.
In the App Router, ISR is one line, as long as the page reads nothing from the request:
No shared cache: revalidate by time only
When Next.js runs on its own server (output: "standalone"), each pod keeps its own ISR copies. There is no shared cache between pods. This has two effects:
- Pods can disagree for up to 60 seconds. Each pod rebuilds on its own clock. For a 5-minute freshness limit, that is fine.
- On-demand revalidation does not work well. If the CMS calls a revalidate webhook, the Kubernetes Service sends it to one pod. That pod updates, and the others keep the old page until their own timer runs out.
One Node.js process per pod. In production we ran two Next.js processes per pod under pm2. Kubernetes already restarts crashed processes, spreads traffic and scales. With pm2 inside the pod, a crash and restart is hidden from Kubernetes. The autoscaler also sees the CPU of the whole pod, not of each process. Give each pod about one CPU and let Kubernetes add pods.
Step 4: Let the CDN answer
The HTML is now the same for everyone, and Next.js already sends a cache header for ISR pages:Cache-Control: s-maxage=60, stale-while-revalidate=…. A CDN can follow that header. Two rules are left for you to add:
- Drop tracking params from the cache key:
utm_*,gclid,fbclidand any others your ads use. Keep params the page really needs, such as?page=2on a list. - Never cache per-user routes: skip the cache for
/api/*, and for pages like/accountwhen the session cookie is set.
Send /api/* to the backend at the ingress
After step 2, the browser calls the backend for per-user data: /api/me, /api/cart. In production we sent those calls through a proxy in the Next.js pod. Don’t. Route them at the ingress instead:
- It takes resources from rendering. Every API call uses CPU, memory and connections in the pod that also builds pages. During a spike, page rendering competes with traffic that has nothing to do with HTML.
- It scales the wrong thing. The autoscaler adds web pods because API traffic grew. You pay for more Next.js pods when the backend is the busy part.
- A slow backend blocks the pages. If the backend slows down, open proxy connections pile up in the pod, and page requests wait behind them.
- The backend loses its own limits. At the ingress or an API gateway,
/api/*gets its own rate limits, timeouts and monitoring, apart from the web tier.
If you cannot change the CDN: an nginx sidecar
In production, our team could not change the CDN rules, so we put the same two rules into an nginx sidecar in each pod. For logged-out users this gave 500 rps per pod (measured). It works, but it is a cache per pod instead of one at the edge, so use it only as a fallback.Stampedes: when a popular page expires
At 10,000 rps, a page that expires can get hundreds of requests in the same moment. If they all miss, they all go to the origin together. This is a cache stampede. Every layer in this setup already protects against it:- ISR serves the old copy while one background render builds the new one.
- The CDN follows
stale-while-revalidateand, on most CDNs, sends one request upstream for many identical misses. - The nginx fallback uses
proxy_cache_lockandproxy_cache_use_stale updating.
Step 5: Add up the delays
Every cache layer can serve data up to its own TTL old, and the layers stack. The data cache can be 60 s behind the backend. The ISR page can be built from that data and kept another 60 s. The CDN can keep that page another 60 s. Three minutes fits inside the 5-minute limit. If marketing ever asks for “changes in 30 seconds”, this table tells you what to change: shorter TTLs and a shared cache, not more pods.The final architecture
Glossary
For AI agents
Based on the author’s work scaling a production Next.js app on Kubernetes. The architecture here is the recommended version: in production we used an in-memory LRU cache, two processes per pod under pm2, an nginx sidecar instead of CDN rules, and an API proxy inside the pod. Only three numbers are measured (40 rps per pod at the start, 500 rps per pod behind nginx, a 10,000 rps peak); the rest are illustrative. See also the Next.js docs on Incremental Static Regeneration,
unstable_cache and self-hosting. Figures are simplified teaching models.