All articles
Web DevelopmentJuly 24, 2026 6 min read

The Middleware Weight Problem: Why Our Edge Bundle Broke Production

Middleware runs on every request. We watched a 40KB dependency turn into 400ms of added latency across every route. Here's how we found it, fixed it, and set guardrails so it never happens again.

The Middleware Weight Problem: Why Our Edge Bundle Broke Production

Middleware in Next.js looks free. It's a small file, it runs on the edge, and the mental model is "a function that returns a response." That framing is what got us into trouble — because middleware runs on every matched request, and the cost of what you import there is paid globally, not per-route.

This is the story of a client project where a well-meaning refactor pushed our p75 TTFB from ~180ms to ~620ms across the entire site, and what we changed to stop it happening again.

The setup: auth-aware routing at the edge

The app was a mid-sized B2B dashboard on the App Router, deployed to Vercel's edge network. Middleware did three jobs:

  1. Read a session cookie and verify it.
  2. Redirect unauthenticated users away from /app/*.
  3. Attach a x-tenant-id header for downstream server components.

The original middleware was around 12KB compiled. Cold starts were fine, warm invocations were sub-10ms, and TTFB was healthy in every region we cared about.

Then someone added "just a small helper" for parsing feature flags out of the cookie.

The innocent-looking import

The helper lived in lib/flags.ts and looked reasonable:

import { parseFlags } from '@/lib/flags';

export function middleware(req: NextRequest) {
  const flags = parseFlags(req.cookies.get('ff')?.value);
  // ...
}

What we didn't notice in review: lib/flags.ts re-exported from a shared lib/index.ts barrel file. That barrel pulled in our analytics client, our logging wrapper, a date library, and a schema validation package. None of it was used by middleware, but the edge bundler couldn't statically prove that, so it all shipped.

Compiled middleware size went from 12KB to just over 380KB. Well inside Vercel's 1MB edge middleware limit, so no build error. No warning. Just a slow, quiet regression.

How we noticed (later than we'd like to admit)

The regression didn't show up in local dev. It didn't show up in preview deployments, because we test those from an office in the same region as the primary edge node. It showed up in a support ticket from a user in Singapore who described the app as "molasses."

Our RUM data confirmed it: TTFB had degraded globally, but the effect was worst in regions far from the code's cold-start locations. Middleware cold starts scale with bundle size, and a 380KB edge function has a meaningfully longer cold start than a 12KB one — especially on V8 isolates that have to parse and compile the JS before your handler even runs.

Rule of thumb we now use internally: if middleware compiled size crosses ~50KB, treat it as a bug until proven otherwise.

Finding what's actually in your middleware bundle

The first useful thing to do is look at the compiled artifact. After a build, Next.js writes middleware to .next/server/middleware.js (or a similar path depending on version). You can inspect it directly:

# rough size check
ls -lh .next/server/middleware.js

# see what got pulled in
npx source-map-explorer .next/server/middleware.js

For a more structured view, @next/bundle-analyzer will show middleware alongside route bundles. What you're looking for:

  • Any dependency you don't recognise as auth/routing logic.
  • Date libraries. Almost always a mistake in middleware.
  • Schema validators (zod, yup, valibot) — sometimes justified, often not.
  • Anything that transitively imports Node built-ins that get polyfilled for the edge runtime.

The barrel-file trap

Barrel files (index.ts that re-exports everything) are the single most common cause of edge bloat we see. Tree-shaking helps in client bundles because bundlers are aggressive there. In middleware, especially with side-effectful modules in the graph, dead code elimination is far less reliable.

Our fix was blunt: middleware imports from concrete file paths only.

// Don't
import { parseFlags } from '@/lib';

// Do
import { parseFlags } from '@/lib/flags/parse';

And lib/flags/parse.ts imports nothing except what it strictly needs. No re-exports, no cross-cutting utilities.

The rewrite: what middleware should and shouldn't do

We went further than fixing the import. We audited what the middleware was doing and moved anything that didn't need to be there.

Keep in middleware

  • Cheap cookie reads.
  • Redirects and rewrites based on request shape.
  • Setting request headers that server components need before rendering.
  • Geo/locale routing decisions.

Move out of middleware

  • Session verification that requires a database or KV round-trip. Do it in a server component or a cached server function instead — you get the same result with better locality and no penalty on static routes.
  • Anything involving crypto beyond HMAC verification of a signed cookie. Full JWT libraries with JWK fetching don't belong here.
  • Feature flag evaluation with remote config. Fetch the config once per render tree, not once per request in middleware.

Here's roughly what our slimmed middleware looks like now:

import { NextResponse, type NextRequest } from 'next/server';
import { verifySessionCookie } from '@/lib/auth/verify-cookie';

export const config = {
  matcher: ['/app/:path*', '/api/private/:path*'],
};

export function middleware(req: NextRequest) {
  const token = req.cookies.get('session')?.value;
  const session = token ? verifySessionCookie(token) : null;

  if (!session) {
    const url = req.nextUrl.clone();
    url.pathname = '/login';
    url.searchParams.set('from', req.nextUrl.pathname);
    return NextResponse.redirect(url);
  }

  const res = NextResponse.next();
  res.headers.set('x-tenant-id', session.tenantId);
  return res;
}

verifySessionCookie is a pure function that does HMAC verification against a secret from process.env. No network, no database, no barrel imports. Compiled size is back under 20KB.

A note on the matcher

The matcher config is not just a routing convenience — it's a performance tool. Without it, middleware runs on every request, including for static assets that Next.js otherwise serves without touching your code. Every project we audit has at least one matcher that's broader than it needs to be.

Be explicit. If middleware only needs to protect /app and /api/private, say so. Don't rely on a catch-all with early returns; the function still has to cold-start and execute.

Guardrails so this doesn't happen again

A one-time fix is worthless if the next PR reintroduces the problem. We added two guardrails.

1. A CI size budget

We added a post-build script that fails CI if middleware exceeds a threshold:

// scripts/check-middleware-size.ts
import { statSync } from 'node:fs';
import { resolve } from 'node:path';

const LIMIT_KB = 60;
const path = resolve('.next/server/middleware.js');
const sizeKb = statSync(path).size / 1024;

if (sizeKb > LIMIT_KB) {
  console.error(
    `Middleware is ${sizeKb.toFixed(1)}KB, limit is ${LIMIT_KB}KB.`
  );
  process.exit(1);
}
console.log(`Middleware size OK: ${sizeKb.toFixed(1)}KB`);

Wired into the build step in CI. Simple, boring, effective. The exact number matters less than the fact that a jump is visible in a PR diff.

2. An ESLint rule for middleware imports

We use no-restricted-imports to block barrel imports from middleware.ts:

{
  "overrides": [
    {
      "files": ["middleware.ts"],
      "rules": {
        "no-restricted-imports": [
          "error",
          {
            "patterns": [
              { "group": ["@/lib", "@/lib/index"], "message": "Import from a concrete path in middleware." }
            ]
          }
        ]
      }
    }
  ]
}

This catches the class of mistake that caused our original regression, at review time.

What the numbers looked like after

We don't publish absolute benchmarks because they depend too much on region, plan, and traffic shape. In our case, moving from ~380KB to ~18KB of compiled middleware, combined with tightening the matcher, roughly restored TTFB to pre-regression levels in every region and cut cold-start variance noticeably. The user in Singapore stopped filing tickets.

The wider lesson is about mental models. Middleware feels like a small file. It isn't. It's a hot path that runs before anything else in your app, and its bundle graph deserves the same scrutiny you'd give a critical-path client component — arguably more, because there's no lazy loading escape hatch at the edge.

Where we'd start

If you haven't looked at your middleware bundle in the last quarter, do this today:

  1. Run a production build and check the size of .next/server/middleware.js. If it's over 50KB, open it up.
  2. Tighten your matcher so middleware doesn't run on routes that don't need it.
  3. Replace any barrel imports in middleware.ts with concrete file paths.
  4. Add a CI size check with a threshold ~20% above your current size. Let it fail loudly the next time someone adds a helper that drags half the codebase along with it.

If you'd like a second pair of eyes on an App Router codebase that's drifted on performance, that's the kind of work our team does — see our web development services for how we typically engage.

#Next.js#Performance#Edge Runtime#TypeScript

Want a team like ours?

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

Start a project