Edge vs Node in production: isolates, cold starts, and the data-locality trap
Edge runtime is V8 isolates: near-zero cold start versus 200-800 ms for Node lambdas, paid with no Node APIs, no native deps, code-size caps. Edge wins when compute is data-free or data is replicated; edge plus a single-region DB pays cross-continent RTT per query.
Checkout p95 from Sydney sits at 480 ms; the traces show the API route running in us-east-1, and cold starts spike it past a second. The team ships the obvious fix: export const runtime = 'edge' on the checkout route. Cold starts vanish from the dashboard — and p95 triples to 1.4 s. Rollback at 2 a.m., postmortem on Monday, and the graph is humiliating in retrospect. The route makes four sequential Postgres queries. Before, a Sydney request paid one ~200 ms hop to Virginia, then four queries at sub-millisecond RTT next to the database. After, compute moved to a Sydney PoP — five milliseconds from the user, and two hundred from the data. Four sequential queries at ~210 ms RTT each is 840 ms of pure speed-of-light tax before a byte of business logic runs. Edge moved the compute toward the user and away from the data, and this route talks to data four times per request. Nobody benchmarked that, because cold start was the only number on the slide.
V8 isolates: what near-zero cold start actually costs
The Edge runtime is not a smaller Node — it is a different execution model. Your code runs in a V8 isolate: a fresh JavaScript context spawned inside an already-running process, the architecture Cloudflare Workers pioneered. Spawning an isolate is allocating a heap and a global object — single-digit milliseconds, often less. A Node lambda cold start, by contrast, is a stack of real work: provision a microVM (~50–200 ms), boot Node itself (~50–100 ms), then require your framework and page graph (50–500 ms, multi-second with heavy dependency trees). That is the honest range behind “edge has ~0 cold starts vs 200–800 ms”: isolates skip the boot, not the physics. Warm invocations are within a few milliseconds of each other on both runtimes — the entire latency argument for edge is the cold path and the geography.
The price is the API surface. An isolate exposes Web APIs only — fetch, Request/Response, URL, crypto.subtle, streams — and none of Node: no fs, no net, no child_process, no native addons. That last one bites hardest: sharp, bcrypt, anything with a .node binary, and classic database drivers with native engines are out — you need WASM builds, HTTP-based serverless drivers, or a different runtime. Dynamic code evaluation (eval, new Function) is banned outright. And bundles are capped — on the order of 1–4 MB compressed depending on plan — because the isolate model only stays cheap if code ships small. Next lets you choose per route segment, which is the whole point: the decision is not app-wide.
// app/api/checkout/route.ts — co-located with the database: stay on Node
export const runtime = 'nodejs';
// app/api/geo-banner/route.ts — data-free, latency-sensitive: edge is free speed
export const runtime = 'edge';
export async function GET(request: Request) {
const country = request.headers.get('x-vercel-ip-country') ?? 'US';
return Response.json({ banner: country === 'DE' ? 'eu-promo' : 'default' });
}Why can an edge isolate cold-start in single-digit milliseconds when a Node lambda routinely takes 200-800 ms?
The data-locality trap
The numbers that decide edge-vs-node are RTTs, and they are brutal in their asymmetry: same-region under 1–2 ms, cross-US 60–80 ms, transatlantic 80–100 ms, US-to-APAC 180–250 ms. A route’s latency is roughly client→compute RTT + N_sequential × compute→data RTT + work. Node co-located with the database makes the long hop once — the client RTT — and every query after that is sub-millisecond. Edge inverts it: the first hop shrinks to single digits, and every sequential query now pays the full compute→data RTT. One query, you roughly break even. Four sequential queries from a Sydney PoP to a Virginia Postgres is 800+ ms of RTT alone — strictly worse than the setup it replaced, which is exactly the incident in the hook. There is a second, quieter cost: classic pooled TCP database connections do not survive the isolate model, so edge database access goes over HTTP-based serverless drivers — each query carrying HTTP overhead instead of a warm pooled socket.
So edge compute wins in exactly two situations. Compute that touches no data: redirects, geo-personalization from request headers, A/B bucket assignment, verifying a signed token with crypto.subtle — these are pure CPU on the request itself, and running them 10 ms from the user instead of 200 is free speed. Or data that is replicated to where the compute runs: edge KV stores, per-PoP caches, regional read replicas — reads land local, and the geography works for you. Together these two conditions draw a single, non-negotiable line: if your route is not data-free and your data is not replicated to the PoP, edge will make things slower — not faster. Everything else — a checkout flow, a dashboard with N queries, anything transactional against a single-region primary — belongs on Node in the database’s region, where sequential queries cost microseconds and the user pays the long hop exactly once.
Middleware is a tax on every request
Next middleware runs before the cache and the router, on every request the matcher lets through — pages, RSC payload fetches, prefetches, and, with a sloppy matcher, static assets too. That makes its latency a flat tax on the whole site: whatever middleware does is added to every TTFB. The discipline that keeps the tax near zero: middleware does routing decisions, header and cookie rewrites, and local verification of signed tokens — crypto.subtle HMAC or JWT signature checks cost tens of microseconds and touch nothing. What it must never do is talk to a database or fetch your own API: a middleware that calls /api/session to gate routes adds an edge→origin round trip plus a DB query in front of every single page load, doubling TTFB globally — and it is invisible in route-level profiling because the cost lands before the route starts.
// middleware.ts — verify locally, never fetch your own API here
import { NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], // exclude assets or they pay too
};
export async function middleware(req: Request) {
const token = req.headers.get('cookie')?.match(/__Host-session=([^;]+)/)?.[1];
if (!token) return NextResponse.redirect(new URL('/login', req.url));
try {
await jwtVerify(token, secret); // local signature math: ~0.05 ms, zero IO
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL('/login', req.url));
}
}▸Why this works
Why does the platform even offer edge for API routes if the data-locality math is this unforgiving? Because the math flips for the workloads edge was designed for: routing, personalization, and auth gating are request-shaped, not data-shaped — they read headers and cookies, not tables. For those, the only latency that exists is client-to-compute, and moving compute into the PoP deletes it. The trap is taking a tool built for data-free request handling and pointing it at a transactional workload, where the same geography that shrank the first hop multiplies every hop after it.
A team gates every route by having middleware fetch /api/session, which checks the session in Postgres. What is the production effect?
- 01Walk the latency equation for edge versus co-located Node, with honest RTT numbers. When does each win?
- 02What may middleware do, what must it never do, and why is the never-rule absolute?
The Edge runtime is a different execution model, not a lighter Node: V8 isolates spawn as fresh contexts inside an already-running process, so cold start is allocating a heap — single-digit milliseconds against the 200-800 ms a Node lambda spends provisioning a microVM, booting Node, and requiring a framework graph. The price is the surface: Web APIs only, no fs or net, no native addons — sharp, bcrypt, and native database engines are out — no eval, and compressed bundles capped around 1-4 MB. Warm performance is comparable; the case for edge is cold paths and geography. And geography is exactly where teams get burned: route latency is client RTT plus sequential queries multiplied by compute-to-data RTT, so moving compute to a PoP five milliseconds from the user and two hundred from a single-region Postgres turns a four-query route into 800+ ms of speed-of-light tax — the p95-got-worse incident. Edge wins in two shapes only: data-free compute (redirects, geo personalization, A/B bucketing, signature verification) and data replicated to the PoP (edge KV, regional read replicas). Everything transactional stays on Node beside the primary, selected per route with export const runtime. Middleware deserves its own paranoia: it runs before the cache on every matched request, so its cost is a flat serial tax on site-wide TTFB — keep it to routing, header rewrites, and local crypto.subtle token checks, exclude static assets in the matcher, and never let it query a database or fetch your own API, because that doubles every page load while hiding from route-level profiling. Now when you see a PR adding export const runtime = 'edge' to an API route, your first question is: how many sequential DB queries does this route make?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.