All articles
DevOps & CloudAugust 20, 2026 6 min read

The Vercel Middleware That Quietly 4x'd Our Function Invocations

A single innocent-looking middleware.ts turned every static asset request into a billable function invocation. Here's how we found it, what it cost, and the matcher config we now use on every Next.js project.

We shipped a fairly boring Next.js 14 app to Vercel in late Q3. Traffic was flat. Then the November invoice landed and function invocations were roughly 4x what they had been in September. No new features, no traffic spike, no marketing push. The culprit was fifteen lines of middleware nobody had touched in months.

This is that story, plus the matcher config and the Sentry + OpenTelemetry setup we now bolt on to every Next.js project so this doesn't happen again.

What middleware on Vercel actually costs you

On Vercel, middleware.ts runs on the Edge Runtime by default. That's cheap per invocation compared to a Node serverless function, but it's not free, and it counts against your Edge Middleware Invocations quota. More importantly: middleware runs before the CDN cache decision on any path its matcher accepts.

That last part is where teams get burned. If your matcher is too broad, middleware fires on:

  • /_next/static/* chunks
  • /favicon.ico
  • Images under /public
  • Prefetched routes triggered by <Link> hovering
  • Health checks from uptime monitors

Each of those is an invocation. On a content-heavy marketing site with ~30 static assets per page, that's a 30x multiplier on top of your actual page views before you've served a single API call.

The default matcher is a trap

The Next.js docs show this pattern all over the place:

export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

It excludes the obvious noisy paths, and if you copy it verbatim it's mostly fine. The problem is what happens when a well-meaning engineer edits it. In our case, someone added an auth check that needed to run on API routes too, so they simplified the matcher to:

export const config = {
  matcher: ['/((?!_next/static).*)'],
}

Looks reasonable. It isn't. That regex still matches /favicon.ico, /robots.txt, /sitemap.xml, /_next/image (note: not _next/static), every file in /public, and every prefetch. On a site with heavy <Link prefetch> usage in the nav, we were firing middleware on hover.

How we noticed (later than we should have)

Honestly, we noticed via the billing dashboard, not via observability. That's the embarrassing part. Our Sentry performance dashboards were healthy. p95 latency was flat. Error rate was flat. Nothing in Vercel's default analytics screamed.

What we didn't have was a cost-per-route panel. Vercel shows you total invocations and total edge middleware invocations, but attributing them to specific paths requires you to instrument it yourself or dig into the log drain.

Here's the query we now run against our log drain (we ship Vercel logs to BigQuery via a Cloud Function; you could do the same with S3 + Athena):

SELECT
  REGEXP_EXTRACT(path, r'^(/[^/?]*)') AS route_prefix,
  COUNT(*) AS invocations,
  COUNTIF(status = 'middleware') AS middleware_hits
FROM `logs.vercel_edge`
WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY route_prefix
ORDER BY middleware_hits DESC
LIMIT 50;

When we finally ran this, the top three rows were /_next, /favicon.ico, and /images. Middleware hits on /images alone were roughly 60% of our total middleware invocations. That's when the room went quiet.

The fix, and why it's fussier than you'd think

The temptation is to just exclude everything static. But Next.js middleware matchers are evaluated as a single regex per entry, and negative lookaheads compose in ways that will bite you if you're careless.

Here's the matcher we settled on:

export const config = {
  matcher: [
    {
      source: '/((?!api/health|_next/static|_next/image|_next/data|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:png|jpg|jpeg|gif|webp|avif|svg|ico|css|js|woff2?|ttf|map)$).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}

Two things worth calling out:

  1. The file-extension exclusion at the end catches anything served from /public that we forgot to whitelist explicitly. Belt and braces.
  2. The missing block skips middleware on prefetch requests. Prefetches don't need auth checks or geo-routing — they're speculative. This alone cut our invocations by another ~35% after the extension exclusion had already knocked out about half.

We also moved our health check to /api/health and excluded it. Uptime monitors hitting your app every 30 seconds add up: 2,880 invocations per day per monitor, per region. If you have three regions and two monitors, that's ~17k invocations a day for nothing.

What we left in middleware, and what we moved out

Not everything belongs in middleware. Our rule of thumb now:

  • Stays in middleware: auth token validation, A/B test bucket assignment, geo-based redirects, bot detection.
  • Moves to the route handler: feature flag evaluation (unless it changes routing), analytics beacons, anything that reads a database.
  • Moves to the CDN layer: cache-control header rewrites, security headers (use next.config.js headers() instead — it doesn't invoke middleware).

That last one is a surprisingly common mistake. If you're using middleware just to append Strict-Transport-Security or Content-Security-Policy, stop. Put it in next.config.js:

module.exports = {
  async headers() {
    return [{
      source: '/:path*',
      headers: [
        { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
        { key: 'X-Content-Type-Options', value: 'nosniff' },
      ],
    }];
  },
};

Those get applied at the edge without spinning up a middleware invocation.

Instrumenting so this doesn't happen again

After the fix landed, we spent a day on observability so the next drift would surface within hours, not weeks.

OpenTelemetry span attributes for middleware

We add a span in middleware itself, tagged with the matched path prefix. This lets us slice invocations by route in whatever backend we're using (we're on Grafana Tempo for one client, Honeycomb for another):

import { trace } from '@opentelemetry/api';

export async function middleware(req: NextRequest) {
  const span = trace.getTracer('edge-mw').startSpan('middleware', {
    attributes: {
      'http.route_prefix': req.nextUrl.pathname.split('/')[1] || 'root',
      'http.is_prefetch': req.headers.get('purpose') === 'prefetch',
    },
  });
  try {
    // ...actual middleware logic
    return NextResponse.next();
  } finally {
    span.end();
  }
}

Caveat: the Edge Runtime doesn't support the full OTel SDK. You'll need @vercel/otel or a lightweight exporter that works with fetch. In our experience, sampling at 5–10% is plenty for cost attribution; you don't need every span.

A budget alert, not just a dashboard

Dashboards are for people who look at them. Alerts are for everyone else. We now set a Vercel spend alert at 130% of the trailing 30-day average, and a secondary alert on middleware invocations specifically at 150%. Both fire to a low-priority Slack channel — not PagerDuty, because this isn't an outage, but it needs a human to notice within a day.

The tradeoff nobody talks about

Tightening your middleware matcher has one real downside: it's now easier to forget to protect a new route. If you add /admin next month and your matcher explicitly lists what to include rather than what to exclude, you have to remember to add it.

We went with exclusion (deny-list) because our app has a lot of public marketing routes and a few authenticated ones, and forgetting to protect a route is caught by our route-level auth checks anyway. If your app is mostly authenticated with a few public exceptions, invert it — use an allow-list matcher and be explicit about what's public. The failure mode of a tight allow-list (broken public page) is more visible than the failure mode of a leaky deny-list (unprotected admin route). Pick the one whose failure mode you'd rather have.

Where we'd start

If you're reading this and you have a Next.js app on Vercel that's been in production for more than six months, do three things this week:

  1. Run a log-drain query grouping edge middleware invocations by path prefix. If anything static shows up in the top 10, your matcher is leaking.
  2. Add the missing prefetch header check to your matcher. It's a one-line change and usually knocks 20–40% off invocations on any site with a decent nav.
  3. Set a spend alert at 130% of your trailing 30-day baseline. Not because you expect an incident, but because the middleware you shipped in 2024 will drift, and the invoice is a bad way to find out.

If you want a hand auditing a live Next.js deployment or setting up OTel on the Edge Runtime, that's the kind of work our DevOps and cloud team does most weeks. Otherwise, go read your matcher regex. Right now. We'll wait.

#Vercel#Next.js#DevOps#Observability#Cost Engineering

Want a team like ours?

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

Start a project