skip to content

After adding a `middleware.ts` to a Next.js app, p95 TTFB rises on marketing pages that are fully prerendered at build time. Why does a prerendered page still pay for middleware, and how would you bring the cost down?

level: seniorimportance: should knowfreq 40%

answer

  1. static removes render, not pipeline
  2. upstream of the filesystem step
  3. matcher decides, rendering mode does not
  4. prefetches match page-shaped patterns
  5. early return still pays the invocation

basics

~20 s

Middleware runs before filesystem routes and before cached or prerendered content is served, so a static page still pays a middleware hop on every matched request. Narrow the matcher to the paths that genuinely need it and keep the function's work minimal.

solid answer

~50 s

Prerendering removes rendering work, not the request pipeline. Middleware sits ahead of the filesystem in that pipeline — before `public/`, before `_next/static`, before the prerendered HTML is chosen — so if the matcher accepts `/pricing`, every request for `/pricing` runs your function before the static response is served. Nothing about a page being static exempts it. The first thing I would check is what the matcher actually covers: an unscoped or catch-all matcher pulls in asset requests and the RSC payload requests that client navigations and link prefetches issue, so invocation counts track link hovers rather than page views. The fixes, in order: exclude build output, the image optimizer and static file extensions; then narrow to the route trees that genuinely need per-request logic, leaving the marketing tree out entirely; then audit what the function does on the hot path, since anything awaiting I/O there is added to the TTFB of every matched request.

code

typescript · 16 lines
typescript
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  return NextResponse.next()
}

export const config = {
  matcher: [
    // only the authenticated area; marketing pages never enter middleware
    '/dashboard',
    '/dashboard/(.*)',
    '/account',
    '/account/(.*)',
  ],
}

go deeper

for a junior

Know that middleware runs before the filesystem serves anything, so a static page is not exempt. The matcher is what decides whether a path is involved.

for a middle

Place middleware precisely in the request pipeline — after config redirects, before rewrites and filesystem routes — and explain why that ordering means prerendered output sits behind the function.

for a senior

Diagnose before changing anything: identify the matched population including prefetch and asset traffic, measure the function's own latency, then fix with exclusions and a narrower matcher rather than an in-function early return.

for a principal

Own middleware as a site-wide synchronous dependency. Set the rule for what is allowed on that hot path, keep the matcher's breadth a reviewed decision, and make sure a dependency failure inside it cannot take down routes that never needed it.

## Why static does not mean untouched It is tempting to reason that a page prerendered at build time is "just a file", and files do not run code. That is true of the *rendering*, not of the *request*. Next processes an incoming request in a fixed order: config-declared headers and redirects, then middleware, then rewrites, then filesystem routes — which is where `public/`, `_next/static` and your prerendered route output actually live — then dynamic routes and fallbacks. Middleware is upstream of that filesystem step. At the moment the matcher is consulted, the router has not yet decided that `/pricing` resolves to a prerendered document. It is a path. If the matcher accepts the path, the function runs, and only afterwards does the static response get served. Prerendering eliminated the render, and middleware reintroduced a per-request hop in front of it — which is exactly the shape of a TTFB regression that appears on the pages you were most confident about. This is not a flaw; it is the point of middleware. Being ahead of route resolution is what lets it rewrite or redirect before anything has resolved. The cost is that the privilege applies to every matched request. ## Diagnosing before fixing Three questions, in order. **What population is actually matched?** Read the matcher literally and enumerate what it lets through. A catch-all with no exclusions admits `/_next/static/*` chunks, `/_next/image` requests, `public/` files, API paths, and — the one people forget — the RSC payload requests that client-side navigation and `next/link` prefetching issue at the target route's own pathname. Those look exactly like page requests to a matcher. On a link-dense page, prefetching alone can multiply invocations well past page views. **How long does the function take?** Middleware is on the critical path of everything it matches. A network call inside it — a session lookup, a flag service, a geo API — adds its latency to every matched request, and its p99 becomes your p95 tail. Middleware that only reads the URL and a cookie is cheap; middleware that awaits I/O is a synchronous dependency in front of your whole site. **Where is the regression concentrated?** Compare TTFB on a matched path against an excluded one. If excluded paths are flat and matched paths moved together by a similar amount, you are looking at fixed per-invocation overhead rather than something specific to one route. ## The fixes, cheapest first **1. Exclude what can never need it.** Start from the deny-list shape and cut build output, the image optimizer, the favicon and static file extensions. This is pure win: those requests have no per-request logic worth running. **2. Narrow to route trees that need it.** If the concern applies only to the authenticated area, match that area explicitly and leave the marketing tree out of the matcher entirely. An allow-list is the strongest available lever because an unmatched request never loads the middleware module — no cold start, no execution, no per-invocation charge on hosts that meter it. The tradeoff to state deliberately: routes added later fall outside an allow-list silently, so it suits stable path sets and wants a test asserting the intended paths match. **3. Filter prefetches if they dominate.** A matcher entry can be an object with `source`, `has` and `missing` conditions, so a `missing` condition on the `next-router-prefetch` header keeps speculative navigation requests out of the function while still covering real ones. Worth reaching for only when measurement shows prefetch traffic is the driver. **4. Take work off the hot path.** Whatever remains matched should do URL-and-header-shaped work: read the path, read a cookie, decide, return. Anything requiring a round trip belongs where it can be scoped to the routes that need it and cached, rather than executed in front of the whole site. ## What not to do Do not "fix" it with an early `return` at the top of the function for paths you do not care about. It reads like scoping and is not: the module has already been loaded and invoked by the time that line runs, so you keep the invocation and its overhead and lose only the body. The matcher is the only mechanism that prevents the invocation itself. Also resist the instinct to make the page dynamic "since it's being touched anyway". The static output is still saving you the render; the middleware hop is a separate, additive cost, and removing prerendering makes the situation worse, not better. ## The sentence that shows seniority "Prerendering removes rendering work, not the request pipeline — middleware runs ahead of the filesystem, so the matcher, not the page's rendering mode, is what decides whether that page pays."

  • A colleague proposes returning early inside the function for marketing paths instead of changing the matcher. Why is that not equivalent?
    Because the invocation has already happened. The routing layer loaded and executed the middleware module before your first line ran, so you keep the cold-start risk, the execution overhead and any per-invocation charge, and save only the body. The matcher is the only lever that prevents the module from being entered at all.
  • How would you confirm that middleware, rather than something else, caused the TTFB regression?
    Compare a matched path against a deliberately excluded one over the same window — if excluded paths stayed flat while matched paths moved together by a similar amount, you are seeing fixed per-invocation overhead. Then correlate invocation counts against page views; a large gap points at assets or prefetch traffic entering through a loose matcher.
  • What makes middleware a risky place to put an I/O call?
    It is a synchronous dependency in front of every matched request, so the dependency's tail latency becomes your site's tail latency and its outage becomes a site-wide outage rather than a degraded feature. If a lookup is genuinely needed, scope the matcher tightly so only routes that require it pay, and cache aggressively.

saying these in an interview costs you the question

  • Assumes a prerendered page bypasses middleware entirely
  • Thinks an early return in the function costs the same as excluding the path
  • Believes middleware only runs on cache misses
  • Blames rendering mode rather than matcher scope for the regression
  • Ignores prefetch and RSC payload requests when counting invocations

context