Blog
A terminal window showing a Next.js rewrites configuration beside a Vercel CPU usage dashboard.
nextjs vercelmiddlewareroutingdebuggingcpu-optimization

How a Vercel CPU warning uncovered four routing bugs in my Next.js app

August 25, 202611 min read

The email said my free team had used 75% of its included Fluid Active CPU, and that at 100% my projects would be paused automatically.

I run MyTreda, inventory and sales software for Nigerian traders. Real people pay for it. "Automatically paused" is not a phrase you want to read about the thing people pay you for, and I'd picked that week to start charging a cohort that had been on free access.

Three hours later I understood the number. Most of what I found had been wrong for longer than the warning had existed.

First: it wasn't all mine

The usage page shows the whole team, not one project. That's obvious in hindsight and completely invisible while you're panicking.

The tell was a row I couldn't explain: 30 cron invocations. My Next.js app defines no cron jobs. Nothing in the repo could produce that number.

Filtering by project split the bill:

fluid CPU
fluid CPU

Forty-one percent of the CPU I was about to get paused over belonged to a different project, one burning ~199ms of CPU per invocation against my app's ~68ms.

If your platform bills at the team level, attribute before you optimize. I almost spent the afternoon tuning the wrong codebase.

What was actually burning CPU

Here's the part that took longest to accept.

I checked the build manifests. Of 76 app routes: 72 fully static, 7 ISR on an hourly revalidate, 3 dynamic. Nothing renders on demand. Nothing does meaningful server work.

So what was invoking functions 94,000 times a month?

Two numbers answered it. Edge Middleware Invocations: 0, meaning my middleware was running in the Node runtime, where Vercel bills invocation and CPU. And the usage page's type breakdown: middleware 2h 1m, functions 59m.

I had a proxy.ts doing host-based routing for a subdomain split: one deployment serving a marketing site, a tenant portal, and an admin panel.

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

Read that matcher carefully. It matches almost everything. Every page view. Every RSC payload. Every <Link> prefetch. Every bot crawl.

A function was booting on every request, in order to serve a file that was already sitting on the CDN. ~68ms of CPU each time, most of it cold-start module init rather than the actual hostname matching. That was close to 100% of the project's CPU bill.

The fix: stop running code

Everything the middleware did was static host-to-path mapping. All of it is expressible declaratively, and next.config.ts redirects and rewrites compile into routes-manifest.json, which the platform's routing layer handles for free:

async rewrites() {
  return {
    beforeFiles: [
      { source: '/', has: onHost(HOST.app), destination: '/portal' },
      {
        source: `/:path(${notPrefixed('portal')}.+)`,
        has: onHost(HOST.app),
        destination: '/portal/:path',
      },
    ],
    afterFiles: [],
    fallback: [],
  };
}

Then delete the middleware. 148 lines gone, and with them essentially the entire CPU bill.

That was the easy part.

The bug that had been live for six days

While inventorying which paths the matcher excluded, I probed production:

app.mytreda.com/templates/product-import-template.csv → 404 mytreda.com/templates/product-import-template.csv → 200

Every top-level directory in public/ has to appear in that exclusion list. If it doesn't, the rewrite prefixes it into a path that doesn't exist. templates wasn't there.

That's the CSV template for bulk product import. It had shipped six days earlier. Not one user had been able to download it since.

Here's why nobody caught it, me included. The bug only exists where the subdomain split applies. Local development runs on localhost:3000, no subdomain, so no rewrite, so the download works perfectly. I had tested it. It worked. It was broken in production the entire time.

The same hole had eaten two more things I hadn't noticed:

  • /offline, the PWA offline fallback, 404 on the only host users actually visit. On an app whose whole pitch is working without a connection.
  • /robots.txt, 404, so the app hosts published no crawl rules at all. Meanwhile /blog was excluded from the matcher, which meant my marketing blog answered on the app subdomain too. Duplicate content on a host that should serve only the product.

A test that can't reach the condition isn't evidence. My local environment was structurally incapable of reproducing an entire class of bug, and I'd been treating "works locally" as if it meant something.

So before touching the routing further, I wrote a checklist: a script that drives a running server with an explicit Host header across all four hosts, asserting status codes and Location headers.

It failed six cases on its first run. All six were mine.

Three things I assumed wrong about rewrites

Order doesn't win. I had a specific rule for / sitting above a catch-all, and assumed first-match. The catch-all took it: / matched (?!…).* too, and rewrote it to /portal/, which resolves to nothing. Both subdomains 404'd at their own root. The fix isn't reordering, it's making the rules mutually exclusive. The catch-all now ends in .+, so / can't match it.

Destinations get matched again. //portal was re-claimed by the same catch-all and became /portal/portal. Whatever a host rewrites into has to be unprefixable on that host. And per-host: excluding /admin globally so the admin subdomain could reach its own group would have made the admin panel reachable on the tenant subdomain.

:path* matches zero segments. /app matched /app/:path*, interpolated an empty destination, and returned a 308 to an empty Location. That path is the start_url in my PWA manifest, the one burned into every home screen the app has ever been installed to.

44 checks passing locally. Time to ship.

Except local was still lying

The remaining gap: next dev and next start implement routes-manifest.json in Next's own server. The platform compiles it into its own routing layer. Same contract, two implementations, and I'd already been bitten once by assuming an environment resembled production.

A preview deployment can't close that gap either, because preview URLs have no subdomains for host conditions to match on.

What does work: point a real subdomain at the branch. app.test.mytreda.com, assigned to the feature branch, DNS-only through Cloudflare so the platform can verify it.

Three checks that passed locally failed there:

/billing next start → /settings/billing deployed → /settings/billing/ /security next start → /settings/profile deployed → /settings/profile/ /integrations next start → /settings/alerts deployed → /settings/alerts/

A zero-segment :path* interpolates to a trailing slash on the deployed routing layer, and doesn't locally.

Look at how quiet that failure is. The destination still resolves, the slash takes its own 308 and the user lands on the right page. Nobody files a bug about a page that loads. It just costs an extra round trip on /portal/billing, which is the payment-provider return URL embedded in live sessions. Four requests to put a paying customer on one page.

That would have shipped. It would have shipped silently, and I'd have had no reason to look for it.

The fix was the same one I'd already applied elsewhere and hadn't applied here: never let a wildcard match zero segments. Bare path and :path+ as separate rules.

Where it ended up

  • 26 unit checks on the exclusion patterns
  • 44 against a production build across four hosts
  • 31 against a real deployment, on the two subdomains that serve users

And a footnote worth its own paragraph: the usage window turned out to be rolling, not a billing cycle. I spotted a counter going down over wall-clock time, which can only happen if old days age off the back. That reframes the threshold from a deadline into a sustained rate, 4 hours over 30 days is 8 minutes a day. I'd been sitting at 6, converging to exactly the 3h that triggered the warning. I was never heading for a pause. I was at equilibrium, just under the line.

What I'd tell myself that morning

Attribute before optimizing. Team-level bills are not project-level bills.

"It works locally" is a claim about your environment, not your code. If local can't reproduce the condition, no subdomain, no TLS, no cold start, it can't clear it either.

Write the checklist before the fix. Mine caught six bugs on first run, three of them in code I'd have sworn was correct, and one in production that had been broken for six days.

The bugs that ship are the quiet ones. A 404 gets reported. An extra redirect hop that still lands on the right page never does.

And the one I keep relearning: I spent a day on this because I was optimizing against a free tier's ceiling rather than asking whether the work was worth doing. The routing cleanup was worth it, it deleted 148 lines and four real bugs. The panic wasn't. Those are separate things, and it took me most of the day to tell them apart.