Request Lifecycle: How Web Frameworks Handle a Request · JavaScript Frameworks: Express, NestJS and Next.js
Next.js: Proxy (Middleware), Rendering and Caching
Why a Next.js app made 40 session-API calls per page view, and what runs on each request: the proxy, route matching, server components, client components and caches.
Scenario
Example app: a Next.js App Router app protects /dashboard. A developer adds a middleware.ts (renamed proxy.ts in Next.js 16) that calls the auth service to verify the session on every request, without a matcher.
What you see in production
- The auth service's traffic is about 40 times the page-view count.
- Pages feel slower everywhere, including the public marketing pages.
- Static assets and image requests show extra latency in tracing.
The naive code
// proxy.ts (middleware.ts before Next.js 16) - no matcher, so it runs for every request
export async function proxy(request: NextRequest) {
const session = await fetch(`${AUTH_URL}/session`, { headers: { cookie: request.headers.get("cookie") ?? "" } });
if (!session.ok && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
Reproduce it
One page view triggers many requests: the HTML or RSC payload, JavaScript chunks, CSS, fonts, images and prefetches of linked routes. The simulation counts how often the proxy calls the auth service.
const pageView = [
"/dashboard",
...Array.from({ length: 22 }, (_, i) => `/_next/static/chunks/${i}.js`),
"/_next/static/css/app.css",
...Array.from({ length: 8 }, (_, i) => `/_next/image?url=/img/${i}.png`),
"/favicon.ico",
...Array.from({ length: 8 }, (_, i) => `/dashboard/report-${i}`), // <Link> prefetches
];
const noMatcher = () => true;
// like: export const config = { matcher: ["/dashboard/:path*"] }
const dashboardOnly = (path) => path === "/dashboard" || path.startsWith("/dashboard/");
function authCalls(matches, verifyRemotely) {
return pageView.filter((p) => matches(p) && verifyRemotely).length;
}
console.log("requests in one page view:", pageView.length);
console.log("no matcher, remote session check: ", authCalls(noMatcher, true), "auth calls");
console.log("matcher, remote session check: ", authCalls(dashboardOnly, true), "auth calls");
console.log("matcher, cookie check only in proxy:", authCalls(dashboardOnly, false), "auth calls (verify in the page / data layer)");
Output
requests in one page view: 41 no matcher, remote session check: 41 auth calls matcher, remote session check: 9 auth calls matcher, cookie check only in proxy: 0 auth calls (verify in the page / data layer)
Root cause
The proxy runs before routing for every request it matches, and without a matcher that means every request: static chunks, images, prefetches. Doing a network call in it multiplies that call by every asset of every page.
The lifecycle, step by step
next.configheaders, redirects and rewrites are applied.- Proxy (
proxy.ts, formerlymiddleware.ts) runs for matched paths: it can rewrite, redirect, set headers or cookies, or return a response. See the proxy file convention. - Routing: filesystem routes, then dynamic routes; static assets in
public/and/_next/staticare served directly. - For a page, server components render on the server (data fetching, with caching where configured), producing HTML for the first load and an RSC payload for client navigations. Layouts persist across navigations.
- Route handlers (
app/api/.../route.ts) handle API-style requests; server actions handle form posts. - In the browser, client components hydrate and become interactive;
<Link>prefetches routes in view. See server and client components.
The fix
- Add a
matcherso the proxy only runs where it's needed, excluding_next/static,_next/image,favicon.icoand public files. - Keep the proxy cheap: an optimistic check (is a session cookie present and well-formed?) to redirect early, and do the real authorisation close to the data: in server components, route handlers and server actions, where it can't be bypassed by a misconfigured matcher.
- Cache session lookups per request in the data layer, not per asset.
Trade-offs
- The proxy is good for redirects, rewrites, A/B routing and headers; it's a poor place for slow I/O.
- Checking auth in each data access point is more code, but it's the layer that protects the data.
Detect it in production
- Compare proxy invocation counts with page views; a ratio far above 1 means the matcher is too broad.
- Trace a page load and look for repeated upstream calls on asset requests.
Key points
- One page view = many requests; an unmatched proxy runs for all of them.
- Order: config redirects/rewrites → proxy → routing → server components / route handlers → client hydration.
- Use a matcher, keep the proxy cheap, and authorise near the data.
Practice questions
What runs, in order, when a request reaches a Next.js App Router app?
Answer
next.config headers/redirects/rewrites, then the proxy (middleware.ts before Next.js 16) for matched paths, then routing (static files served directly), then server components or route handlers render on the server; in the browser client components hydrate and links prefetch routes.
Why should a Next.js proxy (middleware) use a matcher?
Answer
Without it the proxy runs for every request, including static chunks, images and prefetches, multiplying any work it does. A matcher limits it to the paths that need it.
Where should authorisation happen in a Next.js app?
Answer
Close to the data: in server components, route handlers and server actions that read or change data. The proxy can do cheap optimistic checks and redirects, but shouldn't be the only protection.
Open in the app to mark lessons complete, take notes and track your progress.