All articles
Web DevelopmentNovember 6, 2026 6 min read

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:

  1. Request hits /go/newsletter
  2. Middleware runs
  3. Rewrite sends it to /api/track/newsletter
  4. Middleware runs again
  5. 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:

  1. Add a one-line log with the pathname and the next-router-prefetch and x-middleware-rewrite headers. Deploy, wait an hour, read the logs.
  2. Count invocations per real user navigation. If the ratio is above 1.2, you have prefetch or rewrite duplication.
  3. Add an isPrefetch guard and a rewrite guard before any expensive work.
  4. Tighten your matcher to exclude static assets explicitly, not just by prefix.
  5. 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.

#Next.js#Edge Runtime#Performance#Middleware#App Router

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project