TTFB Spike from Vercel Middleware Geolocation Lookup

Vercel middleware doing geo redirects spiking TTFB to 800ms? Here is the runtime, header, and edge config fix that cuts it back under 100ms.
PerformanceVercelCore Web Vitals
May 18, 20266 min read1062 words

The Problem

I ran into this on a client e-commerce site running Next.js 16 on Vercel. They added a small middleware to redirect EU visitors to a localised path. After the deploy, PageSpeed Insights flagged the home page with a TTFB of 820ms on mobile field data — up from 180ms the week before. Lab TTFB on a 4G profile from London came in at 740ms. The LCP score collapsed from 88 to 41 in CrUX within a week.

Server response was the only thing that changed. The HTML payload was the same size. The cache headers were the same. The CDN hit ratio on the static assets was the same. The middleware was doing one tiny thing — reading request.geo.country and either redirecting to /eu/... or rewriting to /us/... — and the entire site's TTFB had moved with it.

The frustrating part: the middleware function was reporting 4ms of execution time in the Vercel logs. Whatever was costing 600ms of TTFB was not the code I wrote.

Why It Happens

Vercel middleware runs at the edge before any cache lookup. That ordering is the whole point — middleware decides what to serve, so the cache cannot run before it. Three things in this specific setup pushed TTFB into the danger zone.

First, geolocation on Vercel is sourced from the request's edge region. When middleware accesses request.geo or any geo header (x-vercel-ip-country, x-vercel-ip-region), the platform stamps those values onto the request before the function runs. That stamping is fast. What is slow is what happens when the user's nearest edge region does not have the requested route warm: the middleware function cold-starts, and an edge cold start in 16's runtime is 150-400ms depending on bundle size.

Second, every middleware response disables the static cache for that route by default. If your home page used to serve from the CDN edge cache with a 5ms TTFB, adding middleware that touches it pushes every request to the function. You go from "edge cache hit, 5ms" to "function invocation, 200ms" in one deploy.

Third, the most expensive bit on this specific app: the middleware was importing a country-to-locale lookup from i18n-iso-countries. The package adds 180kb to the middleware bundle. Edge runtime parses and instantiates that on every cold start. Multiply by however many edge regions you have warm at any moment and most of your users hit a cold instance.

The Vercel middleware caching docs cover the cache disable behaviour, but the cold-start cost from heavy imports is left for you to find.

The Fix

Four layers: move geo logic out of middleware where possible, slim the middleware bundle, opt back into caching with unstable_cache_tag, and verify with the right field-data tool.

Step 1: Push geo logic into a header on the rendered page. If you only need geo for content switching (not URL routing), let the page read the header directly:

// app/page.tsx
import { headers } from 'next/headers';

export default async function Home() {
  const h = await headers();
  const country = h.get('x-vercel-ip-country') ?? 'US';
  const locale = country === 'DE' || country === 'FR' ? 'eu' : 'us';
  return <LocaleHome locale={locale} />;
}

Vercel injects x-vercel-ip-country on every request without any middleware. The page is still server-rendered, still benefits from PPR if you have it enabled, and you skip the middleware function entirely. TTFB drops back to whatever your server render baseline was.

Step 2: When you must redirect, use the matcher and the smallest possible middleware. If you genuinely need a URL redirect (not a content switch), keep middleware but scope it tightly and strip every non-essential import:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const EU_COUNTRIES = new Set(['DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL']);

export function middleware(req: NextRequest) {
  const country = req.headers.get('x-vercel-ip-country') ?? 'US';
  if (!EU_COUNTRIES.has(country)) return NextResponse.next();

  const url = req.nextUrl.clone();
  if (url.pathname.startsWith('/eu')) return NextResponse.next();
  url.pathname = `/eu${url.pathname}`;
  return NextResponse.redirect(url, 308);
}

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

No imports beyond next/server. The country set is a literal. The matcher excludes API routes, static files, and anything with a file extension. Bundle size for this middleware is under 4kb, and the edge cold start drops to around 20-40ms.

Step 3: Re-enable the static cache for the redirect target. The /eu/... and /us/... paths the middleware sends users to should themselves be cacheable. In Next.js 16, mark them with dynamic = 'force-static' if they have no per-request data, or use revalidate:

// app/eu/page.tsx
export const revalidate = 3600;

export default async function EuHome() {
  return <Home locale="eu" />;
}

With this, the redirect target hits the edge cache. The user pays the middleware redirect cost once on the first navigation; every subsequent navigation served from the cache returns in tens of milliseconds.

Step 4: Measure with field data, not lab. Lighthouse runs from a clean cold-start every time and will always show the worst-case TTFB. The number that moves your Core Web Vitals score is the 75th-percentile field TTFB in CrUX. Pull it from PageSpeed Insights API:

curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?\
url=https%3A%2F%2Fexample.com&\
strategy=mobile&\
category=performance" \
| jq '.loadingExperience.metrics.EXPERIMENTAL_TIME_TO_FIRST_BYTE'

A p75 under 800ms is "Good" in Google's classification. Under 600ms is what you want for a healthy LCP downstream.

The Lesson

Middleware on Vercel runs before the cache and on every request. If you only need geo for content switching, read x-vercel-ip-country from the rendered page and skip middleware entirely. If you do need it, strip the bundle to the bare minimum and re-enable caching on the redirect target so the cost is paid once per visitor.

If your TTFB regressed after adding a "simple" middleware and nobody can figure out why, that is the kind of cleanup I do. See my services. For a related performance debugging case, see INP regression from Sentry Session Replay on mobile.

Back to blogStart a project