Vercel Edge Middleware Hit Our Latency Budget Harder Than the Origin
We moved auth and A/B logic into Vercel Edge Middleware expecting a win. Instead, p75 TTFB got worse. Here's what we measured, why it happened, and the rules we now follow before putting anything on the edge.
We shipped a Vercel Edge Middleware refactor last quarter expecting the usual story: logic closer to users, lower TTFB, happier Core Web Vitals. Two weeks in, our p75 TTFB had climbed roughly 90ms in several regions, and our p95 was worse than before. The middleware was doing exactly what the docs promised. The problem was that we had never actually measured whether the edge was faster than our origin for our traffic shape.
This is the write-up we wish we'd read before the migration.
What we moved to the edge and why
The app is a Next.js 14 commerce frontend on Vercel, with a primary origin in iad1 (US East) and a Postgres + API tier in AWS us-east-1. The middleware was handling three things:
- Session cookie validation (JWT verification against a JWKS endpoint)
- A/B test bucket assignment with a signed cookie
- Geo-based redirects for a handful of country-specific storefronts
The pitch to ourselves was clean: all three decisions happen before any route handler runs, so pushing them to the edge should shave a round trip. We'd also stop paying origin compute for requests that just needed a 302 or a header rewrite.
On paper, perfect. In production, not quite.
What the numbers actually looked like
We run OpenTelemetry traces through a self-hosted collector and keep a 10% head sample plus tail-based sampling for anything over 500ms. After the rollout, three signals disagreed with our expectations.
Regional variance was brutal
In iad1 and sfo1, edge middleware added about 8–15ms versus the previous origin-only path. Fine. In São Paulo and Mumbai, where we had very low RPS, we saw cold invocations in the 180–240ms range on the first request of a session. The warm path was fast, but with sparse traffic, "warm" didn't last long.
Our mental model had been: edge means it runs near the user, which means it's fast. The reality is that edge middleware runs on a V8 isolate that still has to be instantiated somewhere, and if that somewhere is a region you barely use, you pay for the privilege every few minutes.
The JWKS fetch was the real killer
JWT verification needs the signing keys. We cached them in middleware memory with a 10-minute TTL. On a warm isolate, that's free. On a cold one, the first request triggers a fetch to our auth provider — which lives in us-east-1. From an edge isolate in Mumbai, that's a ~220ms round trip before the user sees a single byte.
We'd essentially moved the latency from "origin compute, which was already in us-east-1 and had a warm connection pool" to "edge compute in Mumbai calling us-east-1 over a cold TLS connection." The edge was closer to the user but farther from the data it needed.
Middleware ran on requests it didn't need to
By default, Next.js middleware runs on every matching route including static assets if your matcher isn't tight. Our matcher was lazy:
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
That still matched a lot of RSC payload requests, prefetches, and API routes that didn't need auth checks at the edge. We were running JWT verification on /api/health because nobody tightened the matcher during the refactor.
Why "the edge" isn't automatically faster
There are three assumptions baked into most edge-first advice that didn't hold for us:
1. Your traffic is globally distributed and dense. Edge wins when you have enough requests per region to keep isolates warm. If 85% of your traffic is in two US regions and the rest is a long tail, the long tail eats cold starts constantly.
2. Your middleware doesn't need origin data. The moment you call back to a database, an auth provider, or an internal API that lives in one region, the edge is a liability for every region that isn't that one. Edge-to-origin is often slower than user-to-origin because the user's ISP has better peering than you'd think.
3. Your logic fits the runtime. The edge runtime is not Node. No fs, limited crypto, no native modules, a tighter CPU budget. We hit a wall trying to use a library that pulled in a Node-only polyfill, and the workaround added ~20ms of its own.
None of this is a Vercel-specific complaint. The same pattern shows up on Cloudflare Workers and AWS Lambda@Edge. It's just physics plus runtime constraints.
What we changed
We didn't rip middleware out. We split it by cost and locality.
Geo redirects stayed at the edge
These are pure functions of request headers. No network calls, no shared state, minimal CPU. This is the ideal edge workload and it was already working.
if (country === 'DE' && !pathname.startsWith('/de')) {
return NextResponse.redirect(new URL(`/de${pathname}`, request.url));
}
Total added latency: single-digit milliseconds, and it saves an origin hit entirely for mismatched locales.
A/B bucket assignment moved to a cookie on first origin response
We generate the bucket at the origin on the first request of a session, set a signed cookie, and the edge middleware just reads it on subsequent requests. First-request users see one slightly slower response; everyone else gets the fast path. Over a session, this is a clear win versus doing the signing work on every edge invocation.
JWT verification moved back to the origin, but smarter
We kept a cheap signature-shape check at the edge — just enough to reject obviously malformed tokens before they hit origin — and did the full JWKS verification at the origin with a long-lived in-memory cache. The origin has a warm connection pool to the auth provider and the key cache survives across requests properly.
After these changes, p75 TTFB returned to pre-migration levels in low-traffic regions and improved by about 25ms in high-traffic regions. p95 came down meaningfully everywhere because we stopped paying JWKS cold-fetch costs on sparse edges.
The rules we use now
Before anything goes into Edge Middleware, we ask four questions:
- Does it make a network call to a single-region service? If yes, it probably belongs at the origin, or the service needs to be replicated.
- Does it run on every request including prefetches and static-ish paths? If yes, tighten the matcher first, then decide.
- Is the logic a pure function of the request? Headers, cookies, URL — great candidates. Anything that needs shared state or secrets lookup — be careful.
- Do we have traces that would catch a regression per-region? If not, instrument first, migrate second.
That last one is the one we skipped. We had aggregate latency dashboards but not per-region p95s broken out by middleware path. Adding those took half a day and would have caught the regression in the canary.
A note on measuring this honestly
Vercel's analytics will show you edge function duration, but that's isolate CPU time, not wall-clock TTFB from the user's perspective. The two can diverge wildly when there's a cold start or an outbound fetch. We trust three signals now:
- OpenTelemetry spans from the middleware, including any outbound fetch
- Real User Monitoring TTFB bucketed by country
- Synthetic checks from at least four geographies, run every few minutes to keep isolates warm enough that we can tell cold-start regressions from steady-state regressions
If you only have one of these, you'll draw the wrong conclusion at some point.
Where we'd start
If you're staring at a middleware refactor right now, do this before writing any code: pick your three lowest-traffic regions, run a week of RUM TTFB data, and look at the p95. That number is your floor. Any edge logic that calls back to a single-region service will raise that floor, not lower it. Decide what you're willing to spend before you spend it.
And keep the edge for what it's genuinely good at — stateless, pure, request-shaped decisions. Everything else probably wants to stay where your data already is. If you want a second pair of eyes on an edge or observability migration, our DevOps and cloud team has done a few of these the hard way.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Our AWS NAT Gateway Bill Hit $4K/Month Before We Noticed
A quiet NAT Gateway line item grew into the second-largest cost on our AWS bill. Here's how we found it, what was actually causing it, and the three fixes that stuck.
GCP Cloud Run Cold Starts at the 99th Percentile: What Actually Fixed Them
We spent a quarter chasing Cloud Run cold starts for a Node + Python API. Min instances helped, but the real win came from four less obvious changes. Here's the ranked list of what moved p99.
Pulumi vs Terraform in 2026: We Migrated Half Our Stacks and Kept the Rest
We spent nine months running Pulumi and Terraform side by side across production. Here's what we moved, what we didn't, and the specific failure modes that made us stop short of a full migration.
