How to Load Test a Next.js App (App Router)

To load test a Next.js app, first sort your routes by how they render: static and ISR pages are served from cache and are cheap, while dynamic Server Components, route handlers, Server Actions, middleware and the /_next/image optimizer run code on the origin on every request (or every cache miss). Test those origin-bound routes, confirm cache behavior with the x-nextjs-cache header (HIT, MISS, STALE) when self-hosting, and measure TTFB separately from full response time because streaming changes what "latency" means.
Rendering modes behave very differently under load
In the App Router, a route's rendering mode is decided at build time from what it uses: cookies(), headers(), searchParams, uncached fetches, or export const dynamic = 'force-dynamic' make it dynamic; otherwise it is prerendered. The next build output marks each route as static (○), SSG/ISR (●) or dynamic (ƒ). Start your test plan from that list.
| Mode | What runs per request | Cost under load | Typical Cache-Control |
|---|---|---|---|
| Static (○) | Nothing; file served from cache or CDN | Very low | s-maxage=31536000 (or similar long value) |
ISR (● with revalidate) | Cache read; background regeneration after the window | Low, with periodic render spikes | s-maxage=<revalidate>, stale-while-revalidate |
| Dynamic SSR / RSC (ƒ) | Full server render plus data fetching | High, scales with data calls | private, no-cache, no-store, max-age=0, must-revalidate |
Route handlers (route.ts) | Your handler code | Depends on the handler | Whatever you set |
| Server Actions | POST to the page route, runs the action, may re-render | High; often writes | Not cached |
/_next/image | Image fetch, resize and encode on cache miss | Very high CPU on miss | Based on minimumCacheTTL and upstream headers |
Two version notes for the Next.js 14/15 era. Next.js 15 changed defaults: fetch requests and GET route handlers are no longer cached by default, so an app upgraded from 14 can move work from cache to origin without any code change. Next.js 16 renamed middleware.ts to proxy.ts; the cost model described below is the same.
Pick the routes that actually hit origin
A load test that only requests / on a prerendered home page measures your CDN. Build the script from these instead:
- The busiest dynamic pages: product or listing pages that read cookies or search params, dashboards, anything personalized.
- RSC navigation requests: after the first load,
next/linknavigations fetch a Server Component payload, not HTML. These requests carry the headerRSC: 1and an_rscquery parameter and returntext/x-component. Copy a few from the browser network tab and include them. - Prefetches: in production,
<Link>prefetches routes that scroll into view. For dynamic routes this prefetches down to the nearestloading.js. A listing page with 40 product links can generate a burst of prefetch requests per visitor. - Route handlers under
app/api/that the UI calls on load (search, cart, recommendations). - Server Actions for forms such as add-to-cart or comments. They are POST requests to the page URL with a
Next-Actionheader; record one from the browser and replay it with the same headers. /_next/imagewith the widths your pages actually request.
Reading cache headers: HIT, MISS, STALE
When self-hosting with next start, prerendered and ISR responses include x-nextjs-cache:
$ curl -sI https://example.com/products/blue-shirt | grep -i -E 'x-nextjs-cache|cache-control'
cache-control: s-maxage=60, stale-while-revalidate=31535940
x-nextjs-cache: HIT
- HIT: served from the incremental cache, no render.
- MISS: not in cache (first request, or after eviction); rendered on this request.
- STALE: served the old copy because the revalidate window passed; a background regeneration was started.
Dynamic routes do not send the header at all, which is itself a useful check: if a page you expected to be ISR has no x-nextjs-cache, something in its tree made it dynamic. Behind a CDN, read the CDN's own header too (cf-cache-status, x-cache); on Vercel the equivalent is x-vercel-cache.
Log the header value per request during the test, or at least sample it. A run with 95% HIT and a run with 95% MISS are two different tests, and the summary latency will not tell you which one you ran.
ISR on more than one instance
Self-hosted, the default incremental cache lives on each instance's filesystem and memory. With six containers, each one regenerates each page independently, and a freshly scaled-out container starts cold, so the MISS rate jumps right when you add capacity. If that shows up in the test, configure a shared cacheHandler in next.config.js (for example backed by Redis) and re-test.
/_next/image is a CPU hotspot
The image optimizer fetches the source image, resizes it and re-encodes it (WebP or AVIF) on the first request for each URL, width and quality combination. That is one of the most CPU-intensive things a Next.js server does, and it shares the same Node process as page rendering. A page with 30 product thumbnails viewed for the first time after a deploy can trigger 30 encodes at once.
- Results are cached in
.next/cache/images, kept for at leastminimumCacheTTL(60 seconds by default in Next.js 14 and 15) or the upstream image's cache lifetime if longer. On ephemeral containers that cache disappears with each deploy. - Only widths from
deviceSizesandimageSizesare accepted; anything else returns400. Use the exactwvalues from your pages in the test. - Make sure
sharpis available in production. Next.js 15 uses it automatically; on older versions or unusual builds, a missingsharpmeans a much slower fallback. - AVIF encodes are noticeably slower than WebP. If
formatsincludesimage/avif, test cold-cache image load explicitly.
Test it twice: once warm (repeat the same URLs) and once cold (fresh deploy or a cleared cache directory). If cold-cache p95 for pages climbs while images are being generated, move image optimization to a CDN or image service, or pre-generate common sizes.
Middleware cost
Middleware runs before every request its matcher covers, including cached pages, so its cost is paid even on HITs. In Next.js 14 and 15 it runs in the Edge runtime by default (with a Node.js runtime option added in 15.x). Common problems under load:
- No
matcher, so middleware runs on/_next/static,/_next/imageand favicon requests too. - A network call per request: session validation against an auth API, geolocation lookups, feature flag fetches. Every one adds its latency to every page and multiplies load on that dependency.
// middleware.ts: skip static assets and images
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'],
};
A quick way to see middleware cost: run the same cached page with and without a path the matcher excludes, and compare p50.
Self-hosted Node vs Vercel
Self-hosted (next start, standalone output, containers)
- One Node process does its rendering on a single main thread. Run about one process per core (multiple containers or a process manager in cluster mode) and confirm throughput scales linearly as you add instances.
- Watch event loop lag, not just CPU. A synchronous JSON transform or heavy markdown render blocks every concurrent request on that instance; symptoms are covered in why latency increases under load.
- Set container memory limits with headroom and set
--max-old-space-sizebelow the limit, or the container is killed instead of the heap collecting. - For Kubernetes-specific scaling (HPA lag, readiness probes during ramp-up), see Kubernetes load testing.
Vercel
- Static and ISR content is served from Vercel's edge cache; dynamic routes run as functions that scale out automatically. The load test therefore measures function cold starts, concurrency scaling, and your data layer more than "server capacity".
- Function duration, memory, concurrency and other limits depend on your plan and configuration and change over time. Read the current Vercel documentation for your plan rather than relying on numbers from blog posts.
- Platform protections can challenge or block load test traffic, and usage-based billing means a big test costs money. Check Vercel's policy on load testing before you run one and start small.
- Your database usually breaks first: many function instances each opening connections can exhaust a database that was fine with a few long-running servers. Use a pooler. The serverless load testing guide covers this pattern.
Measure TTFB and full response separately
App Router pages stream. With loading.js or <Suspense> boundaries, Next.js flushes the shell immediately and streams the rest as data resolves. That makes TTFB small even when the slow part of the page takes seconds.
- In k6,
http_req_waitingis roughly TTFB andhttp_req_durationincludes receiving the whole streamed body. Set thresholds on both for streamed routes. - A protocol-level load test measures the server, not the browser. Hydration, JavaScript bundle cost and Core Web Vitals need a browser tool such as Lighthouse or WebPageTest, run at a few points during the load test to see how they degrade.
- Percentiles matter more than averages here; see p95 vs p99.
An example test plan for a Next.js app
- Inventory: run
next build, list dynamic (ƒ) routes, route handlers, Server Actions and image widths. - Warm cache run: static and ISR pages only, confirm
x-nextjs-cache: HITand CDN hits. - Origin run: dynamic pages, RSC navigation payloads and route handlers in an arrival-rate (fixed RPS) model, stepping up until p95 crosses your threshold. In LoadTester you can run this in RPS mode with a p95 threshold and auto-stop so the step test stops itself at the knee.
- Cold deploy run: redeploy, then immediately run the realistic mix including
/_next/image. Compare against the warm run. - Scale check: double the instance count and re-run the origin test. Throughput should roughly double; if not, the bottleneck is shared (database, cache handler, upstream API). See throughput vs latency for reading the result.
Route inventory from next build; matcher on middleware; sharp present; shared cacheHandler if you run more than one instance with ISR; RSC and prefetch requests in the script; separate thresholds for TTFB and full response; one cold-cache run after a deploy.
FAQ
Is it worth load testing a Next.js site that is mostly static?
Test the static pages briefly to confirm they are served from cache, then spend your time on what reaches the Node server: dynamic routes, route handlers, Server Actions, image optimization, and ISR revalidation. A mostly static site can still fall over because of /_next/image or one uncached dynamic page linked from everywhere.
Why do my ISR pages show x-nextjs-cache: STALE during the test?
STALE means Next.js served the cached copy after the revalidate window expired and started regenerating the page in the background. That is expected stale-while-revalidate behavior. If you see STALE on nearly every request, your revalidate time is shorter than the interval between requests per page, so the server is regenerating constantly.
Can I load test an app deployed on Vercel?
Read Vercel's current documentation and policies first. Platform protections can challenge or block traffic that looks like an attack, and usage-based billing means a large test has a real cost. Coordinate with the platform as their docs describe, and start with small tests against a preview or a dedicated deployment.
Should the load test request RSC payloads as well as HTML?
Yes, if your users navigate with next/link. After the first page load, client navigations fetch React Server Component payloads (requests with the RSC: 1 header and an _rsc query parameter) instead of HTML. Link prefetching also generates these requests, so they are a meaningful share of origin traffic.
How many Next.js instances do I need per CPU core?
A single next start process does most of its rendering work on one main thread, so it cannot use more than about one core for rendering. Run roughly one process or container per core and put them behind a load balancer, then confirm with a load test that throughput scales as you add instances.