3.7 — Middleware, edge & runtimes¶
Module 3 · Lesson 7 · 🟡 Intermediate · ~30 min
What you'll learn¶
- Why middleware is the most expensive 20 lines in most Next.js commerce apps
- Matcher configuration that actually excludes what you think it does
- Edge vs Node runtime: the real trade‑offs for commerce workloads
- Where to run geo, A/B, and auth logic instead
Middleware runs on every matching request¶
That's the whole lesson in one sentence. Middleware executes before the cache, before rendering, on every request that matches — including static assets if your matcher is sloppy, and including requests that would otherwise be served entirely from the CDN.
At Aurora's 38M sessions/month with ~14 requests per session, middleware runs roughly 530M times a month. A 40 ms middleware is 40 ms added to every TTFB, including cache hits that would otherwise be 20 ms. It doubled the TTFB of the pages they'd just spent a quarter making static.
Version note: Next.js 16 renames
middleware.tstoproxy.ts. The execution model and everything in this lesson is unchanged; only the filename and some typings differ. Check your version's docs for the exact file convention.
Fix the matcher first¶
The default matcher in most examples is far too broad.
// ❌ Runs on literally everything, including images and JS chunks
export const config = { matcher: '/:path*' };
// ⚠️ Common copy-paste — better, but still runs on every page request
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
// ✅ Explicit: only the paths that genuinely need middleware
export const config = {
matcher: [
'/', // home: market routing
'/c/:path*', // category: market routing + A/B
'/p/:path*', // product: market routing + A/B
'/checkout/:path*', // auth guard
'/account/:path*', // auth guard
],
};
Aurora's PLP/PDP didn't need middleware at all after they moved market routing into the URL structure. Removing it from those matchers cut ~28 ms from p75 TTFB across 58% of requests.
Audit what your matcher actually matches:
// scripts/test-matcher.ts — run your real matcher against real URL samples
const MATCHER = /^\/((?!api|_next\/static|_next\/image|favicon\.ico).*)$/;
const SAMPLES = [
'/', '/c/womens-knitwear', '/p/wool-overshirt-navy',
'/_next/static/chunks/main-abc.js',
'/_next/image?url=%2Fhero.jpg&w=828',
'/api/cart', '/robots.txt', '/sitemap-products-3.xml',
'/assets/logo.svg', '/manifest.webmanifest',
];
for (const url of SAMPLES) {
console.log(`${MATCHER.test(url) ? '🔴 RUNS' : '⚪️ skip'} ${url}`);
}
// Anything static showing 🔴 is pure waste, multiplied by your request volume
Note that /robots.txt, /sitemap*.xml, /manifest.webmanifest, and files in /public all match
the "common copy‑paste" pattern above. On a large catalog, sitemap requests from crawlers alone can
be millions of unnecessary middleware invocations.
Keep middleware minimal¶
Middleware should do routing decisions only. No data fetching, no crypto beyond a signature check, no JSON parsing of large bodies.
// ❌ 180ms of middleware on every request
export async function middleware(req: NextRequest) {
const session = await fetch(`${API}/session`, { // 90ms network call 💀
headers: { cookie: req.headers.get('cookie') ?? '' },
}).then((r) => r.json());
const flags = await fetch(`${FLAGS_API}/evaluate`, { /* … */ }); // 70ms 💀
const geo = await lookupGeo(req.ip); // 20ms 💀
// …then routing decisions
}
// ✅ Pure, local, ~1-3ms
import { NextRequest, NextResponse } from 'next/server';
const MARKET_BY_COUNTRY: Record<string, string> = {
US: 'us', CA: 'ca', GB: 'uk', DE: 'de', FR: 'fr',
};
export function middleware(req: NextRequest) {
const res = NextResponse.next();
// 1. Geo comes from a header the CDN already computed — no lookup
const country = req.headers.get('x-vercel-ip-country')
?? req.headers.get('cf-ipcountry')
?? 'US';
res.headers.set('x-market', MARKET_BY_COUNTRY[country] ?? 'us');
// 2. A/B bucket from an existing cookie, or assign one deterministically.
// No network call — a hash, not a flag-service round trip.
let bucket = req.cookies.get('ab_pdp_layout')?.value;
if (!bucket) {
bucket = hashToBucket(req.cookies.get('anon_id')?.value ?? crypto.randomUUID());
res.cookies.set('ab_pdp_layout', bucket, {
maxAge: 60 * 60 * 24 * 30, sameSite: 'lax', path: '/',
});
}
res.headers.set('x-ab-pdp-layout', bucket);
// 3. Auth guard: check for the presence of a cookie, don't validate it here.
// Validation happens in the route, where you can cache and where a failure
// can render a real page.
if (req.nextUrl.pathname.startsWith('/checkout') && !req.cookies.has('session')) {
return NextResponse.redirect(new URL('/login?next=/checkout', req.url));
}
return res;
}
function hashToBucket(seed: string): 'a' | 'b' {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return (h >>> 0) % 2 === 0 ? 'a' : 'b';
}
Middleware budget: ≤ 5 ms p95. Measure it:
export function middleware(req: NextRequest) {
const t0 = Date.now();
const res = doMiddleware(req);
res.headers.set('server-timing', `mw;dur=${Date.now() - t0}`);
return res;
}
Then chart server-timing from your RUM. If middleware is over 5 ms, something in it is doing
work that belongs elsewhere.
Middleware and caching: the interaction that surprises people¶
Middleware that sets a cookie or a Vary header can make responses uncacheable.
// ⚠️ Setting a cookie on every response can defeat CDN caching depending on
// your CDN's configuration — many treat Set-Cookie as "do not cache".
export function middleware(req: NextRequest) {
const res = NextResponse.next();
res.cookies.set('last_seen', Date.now().toString()); // 💀 on a cacheable page
return res;
}
// ✅ Only set cookies when they're missing, and never on cacheable paths
export function middleware(req: NextRequest) {
const res = NextResponse.next();
if (!req.cookies.has('anon_id')) {
res.cookies.set('anon_id', crypto.randomUUID(), {
maxAge: 60 * 60 * 24 * 365, sameSite: 'lax', path: '/', httpOnly: true,
});
}
return res;
}
Also: middleware runs before the Full Route Cache lookup, so a rewrite changes which cache entry is consulted. That's how per‑market variants work — and it's how you accidentally fragment your cache into thousands of entries if you rewrite on something high‑cardinality (a user ID, a session, a raw UA string).
Cardinality rule: anything middleware puts into the routing decision multiplies your cache entries. 5 markets × 2 A/B buckets = 10 variants: fine. 5 markets × 2 buckets × 40 currencies × device class = 800 variants per URL: your cache is now useless.
Edge vs Node runtime¶
| Edge runtime | Node runtime | |
|---|---|---|
| Cold start | ~0–5 ms | 100–800 ms (serverless) / 0 (long‑running server) |
| Geographic distribution | Runs near the user | Runs in your region(s) |
| Available APIs | Web standard subset | Full Node.js |
| npm compatibility | Limited (no fs, many packages fail) |
Full |
| Memory limits | Tight (often 128 MB) | Generous |
| CPU limits | Strict, short‑lived | Generous |
| Database access | Needs HTTP‑based drivers or a proxy | Native TCP drivers, connection pooling |
| Bundle size limits | Strict (often 1–4 MB) | Generous |
When Edge wins: the work is cheap, latency‑sensitive, and doesn't need a database — routing, redirects, header manipulation, A/B assignment, geo, simple token verification, serving cached content.
When Node wins — which is most commerce rendering:
- Database proximity beats user proximity. A PDP render makes 3–6 backend calls. Running the render at the edge in Sydney while your database is in Virginia means 3–6 × 200 ms round trips. Running it in Virginia means one 200 ms round trip for the HTML and the rest is local. Edge rendering is slower than regional rendering whenever the page is data‑heavy — which is every commerce page.
Edge render in Sydney, DB in Virginia:
user→edge 20ms + 5 sequential-ish DB calls × 200ms = ~1,020ms
Node render in Virginia, DB in Virginia:
user→origin 200ms + 5 DB calls × 5ms (parallel) = ~225ms
- Connection pooling. Edge functions can't hold a TCP pool; every invocation needs an HTTP data proxy, which adds latency and a dependency.
- npm reality. Commerce apps depend on SDKs (payments, search, CMS) that often use Node APIs.
Aurora's split:
| Layer | Runtime | Why |
|---|---|---|
| Middleware | Edge | Pure routing, no data |
| Page rendering | Node, multi‑region | Data‑adjacent; pooled connections |
| API routes (cart/checkout) | Node | DB + payment SDKs |
| Simple API routes (geo, health, redirects) | Edge | Cheap and latency‑sensitive |
The real fix for global latency isn't the edge runtime — it's multi‑region Node deployment with regional read replicas. See 8.6.
Alternatives to middleware¶
For each common middleware job, there's usually a cheaper place to do it:
| Job | Middleware cost | Better option |
|---|---|---|
| Geo → market routing | Every request | Market in the URL path (/uk/p/...); links generated correctly server‑side |
| A/B assignment | Every request | CDN edge worker (runs before your origin), or a cookie set once on first visit |
| Auth guard | Every request on protected paths | Fine in middleware (cookie presence only), validate in the route |
| Locale detection | Every request | Accept-Language handled once at the entry point, then persisted in the URL |
| Bot detection | Every request | CDN/WAF layer — it's better at it and it's free |
| Legacy URL redirects | Every request | CDN redirect rules or next.config.ts redirects() (handled before middleware) |
| Adding security headers | Every request | next.config.ts headers() — static, no runtime cost |
// next.config.ts — these cost nothing at runtime; middleware costs on every request
const config: NextConfig = {
async redirects() {
return [
{ source: '/products/:slug', destination: '/p/:slug', permanent: true },
{ source: '/category/:slug', destination: '/c/:slug', permanent: true },
];
},
async headers() {
return [{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
}];
},
};
A common anti‑pattern: 400 legacy redirects implemented as a lookup table in middleware. That table is parsed and searched on every request forever. Put them in
redirects()(or better, in the CDN) where they cost nothing.
Common mistakes¶
| Mistake | Cost |
|---|---|
| Broad matcher | Middleware on static assets and sitemaps |
| Network calls in middleware | +50–200 ms on every request |
| Setting cookies on cacheable responses | Defeats CDN caching |
| High‑cardinality rewrites | Cache fragmentation |
| Full session validation in middleware | Latency on every request; do a presence check instead |
| Edge runtime for data‑heavy rendering | Slower than regional Node, by a lot |
| Redirect tables in middleware | Permanent per‑request cost for a static mapping |
| Not measuring middleware duration | It's invisible in most dashboards |
Lab 3.7 — Middleware audit¶
- Measure it. Add the
server-timingheader and chart p50/p95 from RUM for a day. - Test your matcher with the script above against 20 real URLs from your access logs. Count how many static/asset/sitemap requests it matches.
- Inventory the work. List everything middleware does. For each, check the alternatives table and move what you can.
- Remove all network calls. If middleware needs data, it's the wrong layer.
- Check cache interaction: does middleware set cookies or
Varyheaders on cacheable paths? Verify CDN hit ratio before/after tightening it. - Reconsider Edge routes. For any route with
runtime = 'edge', measure its p75 TTFB against a Node deployment in your data region. If it's data‑heavy, Node usually wins.
Checklist¶
- Matcher is an explicit allowlist, not a broad exclusion regex
- Middleware p95 ≤ 5 ms, measured via
server-timing - Zero network calls in middleware
- No cookies set on cacheable responses (except one‑time anon ID)
- Rewrite cardinality is small and known
- Redirects live in
next.config.tsor the CDN, not middleware - Security headers set statically, not per request
- Runtime chosen per route based on data proximity, not fashion