Why Your Next.js Middleware Is Secretly Running Twice (And How to Stop It)
A war story about a Next.js app where middleware ran twice per request, doubled our auth bill, and broke rate limiting. Here's what caused it and how we found it.
Our auth provider bill jumped 38% in a week with no traffic increase. The culprit wasn't a loop, a retry, or a bot — it was Next.js middleware running twice on roughly a third of our requests. Here's the breakdown of how we found it, why it happens, and the patterns that actually fix it.
The symptom: duplicate session reads
The first signal came from our auth provider's dashboard: getSession calls were roughly 1.6× our actual request count. Our middleware pulls the session cookie, validates it against the provider, and attaches a user ID header for downstream route handlers. One request should mean one call. We were seeing nearly two.
Our rate limiter — a simple sliding window keyed by IP in middleware — started throwing false 429s during normal browsing. Users complained that clicking a link, hitting back, then clicking again would occasionally lock them out. That was the second clue.
The third was the smoking gun. We added a console.log with a random request ID at the top of middleware.ts and tailed our edge logs. Navigating between two pages produced three middleware invocations, not two. One of them had no sec-fetch-dest header set to document. It was a prefetch.
Prefetches are requests too
The App Router aggressively prefetches <Link> targets as they enter the viewport. In production, with the default prefetch behaviour, hovering or scrolling near a link triggers a background request to fetch the RSC payload for that route. That request goes through your middleware. Every time.
If your middleware does real work — session validation, geo lookups, A/B bucket assignment, rate limiting — you're paying that cost for routes the user may never visit. Worse, if your rate limiter counts those prefetches, users can hit limits without ever clicking anything.
You can see this yourself with a minimal middleware:
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const id = crypto.randomUUID().slice(0, 8);
const purpose = req.headers.get('next-router-prefetch') ?? req.headers.get('purpose') ?? 'none';
console.log(`[mw ${id}] ${req.method} ${req.nextUrl.pathname} purpose=${purpose}`);
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Load a page with a few <Link> components on it, scroll, and watch the log. You'll see purpose=prefetch entries for routes nobody clicked.
The header you actually want to check
Next.js sets next-router-prefetch: 1 on App Router prefetch requests. There's also the browser-standard Sec-Purpose: prefetch that Chromium sends for speculative loads. Checking both is defensive:
function isPrefetch(req: NextRequest): boolean {
return (
req.headers.get('next-router-prefetch') === '1' ||
req.headers.get('purpose') === 'prefetch' ||
req.headers.get('sec-purpose')?.includes('prefetch') === true
);
}
We use this to skip expensive work. For session reads, we still want to validate — prefetched RSC payloads contain data that depends on auth state — but we skip rate limiting and A/B assignment.
The second cause: internal rewrites
Prefetches explain roughly half the duplicates. The rest came from something less obvious: our own rewrites.
We have a rewrite in next.config.js that maps /go/:slug to an internal tracking route, which then redirects to the real destination. The flow looks like this:
- Request hits
/go/newsletter - Middleware runs
- Rewrite sends it to
/api/track/newsletter - Middleware runs again
- Handler returns a 307 to the real URL
Middleware runs on both the incoming path and the rewritten path by default. The x-middleware-rewrite header is set on the first pass, and if you don't check for it, you re-execute everything on the second pass against the rewritten URL.
The fix is to detect the second pass and bail early:
export function middleware(req: NextRequest) {
// Already rewritten — skip the expensive work
if (req.headers.get('x-middleware-rewrite')) {
return NextResponse.next();
}
if (isPrefetch(req)) {
// Still attach auth, but skip rate limit + analytics
return attachAuthOnly(req);
}
return fullPipeline(req);
}
There's a related footgun with NextResponse.rewrite() called from inside middleware itself. If your matcher is broad and your rewrite target also matches, you'll loop until Next.js kills the request with a 500. We've seen this happen with locale rewrites that forgot to exclude the already-prefixed paths.
Fixing the matcher
Most "middleware runs twice" problems compound because the matcher is too greedy. The default matcher in a lot of starter templates looks like this:
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};
That excludes /api entirely, which is fine until you add server actions (which post to the page route, not /api) or route handlers that genuinely need auth. The opposite failure — a matcher that includes everything, including static assets — means middleware runs for every image, font, and chunk request.
A tighter matcher we use in production:
export const config = {
matcher: [
// Match everything except:
// - Next.js internals (_next/static, _next/image, _next/data)
// - Static files with common extensions
// - The health check
'/((?!_next/|healthz|.*\\.(?:ico|png|jpg|jpeg|svg|webp|avif|woff2?|ttf|otf|css|js|map)$).*)',
],
};
Note the escaped dot and the non-capturing group. Matcher regex is RE2-flavoured, not JavaScript regex, so some patterns you'd expect to work silently don't. Test your matcher by hitting a range of URLs and logging what fires. Don't trust it by inspection.
Matcher precedence surprises
If you use multiple matchers with has and missing conditions, they're OR'd together, not AND'd. We tripped on this trying to run middleware only on authenticated requests to protected routes. The config looked right; the behaviour was middleware running on every unauthenticated request to the homepage because one of the matcher entries was broader than we thought.
When the logic gets complicated, drop the config-level conditions and put the branching inside the middleware function. It's easier to reason about, easier to test, and the cost of an early return NextResponse.next() is negligible on the edge.
Measuring the fix
After the prefetch guard and the rewrite guard went in, our auth provider calls dropped by roughly a third — close to what the duplicate rate had been. The false 429s stopped the same day. Our edge function duration P50 improved noticeably because we were skipping the full pipeline on prefetches and rewrites, though we don't publish specific numbers because yours will depend on what your middleware actually does.
If you want a quick sanity check before diving in, add a header in middleware that records which branch ran:
const res = NextResponse.next();
res.headers.set('x-mw-branch', isPrefetch(req) ? 'prefetch' : 'full');
return res;
Then in your observability tool, group request counts by x-mw-branch. If prefetch is a meaningful slice of traffic and you weren't handling it separately, you've found free performance.
When middleware is the wrong tool
The deeper lesson from this incident is that we were asking middleware to do too much. Edge middleware runs before the request is routed, on every matching path, under tight CPU and memory limits. It's great for cheap decisions: redirects, header manipulation, geo-based rewrites, bot blocking. It's a bad place for anything that touches a database or calls a third-party API on every request.
For session validation specifically, we moved to a model where middleware only checks whether a session cookie exists and is well-formed. The actual validation against the auth provider happens in a cached server component at the layout level, deduplicated with React's cache() so it runs once per request even across multiple consumers. Rate limiting moved to a dedicated route segment with its own, tighter matcher.
If you're architecting this from scratch, think of middleware as a router, not a controller. Route the request to the right place; let the right place do the work.
Where we'd start
If you suspect your middleware is running more than you think:
- Add a one-line log with the pathname and the
next-router-prefetchandx-middleware-rewriteheaders. Deploy, wait an hour, read the logs. - Count invocations per real user navigation. If the ratio is above 1.2, you have prefetch or rewrite duplication.
- Add an
isPrefetchguard and a rewrite guard before any expensive work. - Tighten your matcher to exclude static assets explicitly, not just by prefix.
- Move anything that calls a database or external API out of middleware into a cached server component or a dedicated route handler.
If you'd rather have someone else audit the edge layer of a production Next.js app, that's the kind of work we do on our web development engagements.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
React 19's `useFormStatus` Is a Trap If You Use It Wrong
`useFormStatus` looks like the obvious way to wire pending states into forms with Server Actions. It is — until you nest it, memoize it, or try to read it from the component that owns the form.
Streaming Metadata in Next.js: Why Our Twitter Cards Showed Yesterday's Title
Next.js 15 streams metadata by default so pages render faster, but social scrapers don't wait around. Here's how we tracked down stale Twitter cards and the pattern we settled on in production.
The `use` Hook Meets Suspense: What Actually Happens When You Pass a Promise to a Client Component
React 19's `use` hook lets you unwrap promises inside client components. It's elegant until a parent re-renders and your tab goes blank. Here's what's actually happening and how to not get burned.
