How-to

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

Updated October 27, 2026•10 min read•Next.js•Node.js

Written by

Reviewed by the LoadTester editorial team. Review process: see the editorial policy.

Published
2026-10-27
Last reviewed
2026-10-27
Author
Kristian Razum
How to Load Test a Next.js App (App Router): LoadTester illustration

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.

ModeWhat runs per requestCost under loadTypical Cache-Control
Static (○)Nothing; file served from cache or CDNVery lows-maxage=31536000 (or similar long value)
ISR (● with revalidate)Cache read; background regeneration after the windowLow, with periodic render spikess-maxage=<revalidate>, stale-while-revalidate
Dynamic SSR / RSC (ƒ)Full server render plus data fetchingHigh, scales with data callsprivate, no-cache, no-store, max-age=0, must-revalidate
Route handlers (route.ts)Your handler codeDepends on the handlerWhatever you set
Server ActionsPOST to the page route, runs the action, may re-renderHigh; often writesNot cached
/_next/imageImage fetch, resize and encode on cache missVery high CPU on missBased 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/link navigations fetch a Server Component payload, not HTML. These requests carry the header RSC: 1 and an _rsc query parameter and return text/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 nearest loading.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-Action header; record one from the browser and replay it with the same headers.
  • /_next/image with 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 least minimumCacheTTL (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 deviceSizes and imageSizes are accepted; anything else returns 400. Use the exact w values from your pages in the test.
  • Make sure sharp is available in production. Next.js 15 uses it automatically; on older versions or unusual builds, a missing sharp means a much slower fallback.
  • AVIF encodes are noticeably slower than WebP. If formats includes image/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/image and 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-size below 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.

Streaming response timeline: TTFB versus full response time 0 ms 1,400 ms TTFB ~120 ms: shell + loading UI flushed Suspense boundaries stream as data resolves full response ~1,300 ms
Illustrative: a streamed page can show a 120 ms TTFB while the slow data behind a Suspense boundary takes over a second. Report both.
  • In k6, http_req_waiting is roughly TTFB and http_req_duration includes 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

  1. Inventory: run next build, list dynamic (ƒ) routes, route handlers, Server Actions and image widths.
  2. Warm cache run: static and ISR pages only, confirm x-nextjs-cache: HIT and CDN hits.
  3. 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.
  4. Cold deploy run: redeploy, then immediately run the realistic mix including /_next/image. Compare against the warm run.
  5. 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.
Checklist

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.

Run this as a real load testFree plan: 10 VUs, 50 RPS, live p95/p99 and error charts, no credit card. Starter $19/mo (250 VUs) or Pro $49/mo (2,000 VUs) with a 7-day free trial when you need more.
Start freeCompare plans