Edge runtime, V8 isolates, the cold start myth, geo-routing, A/B testing, auth at the edge, and why I moved some things back to Node.js. A balanced look at edge computing.
The word "edge" gets thrown around a lot. Vercel says it. Cloudflare says it. Deno says it. Every conference talk about web performance inevitably mentions "running at the edge" like it's a magic incantation that makes your app fast.
I bought into it. I moved middleware, API routes, even some rendering logic to the edge runtime. Some of those moves were brilliant. Others I quietly moved back to Node.js three weeks later after debugging connection pool errors at 2 AM.
This post is the balanced version of that story — what the edge actually is, where it genuinely shines, where it absolutely doesn't, and how I decide which runtime to use for each piece of my application.
Let's start with geography. When someone visits your website, their request travels from their device, through their ISP, across the internet to your server, gets processed, and the response travels all the way back. If your server is in us-east-1 (Virginia) and your user is in Tokyo, that round trip covers roughly 14,000 km. At the speed of light through fiber, that's about 70ms just for the physics — one way. Add DNS resolution, TLS handshake, and any processing time, and you're easily looking at 200-400ms before your user sees a single byte.
The "edge" means running your code on servers distributed globally — the same CDN nodes that have always served static assets, but now they can also execute your logic. Instead of one origin server in Virginia, your code runs in 300+ locations worldwide. A user in Tokyo hits a server in Tokyo. A user in Paris hits a server in Paris.
The latency math is simple and compelling:
Traditional (single origin):
Tokyo → Virginia: ~140ms round trip (physics alone)
+ TLS handshake: ~140ms more (another round trip)
+ Processing: 20-50ms
Total: ~300-330ms
Edge (local PoP):
Tokyo → Tokyo edge node: ~5ms round trip
+ TLS handshake: ~5ms more
+ Processing: 5-20ms
Total: ~15-30ms
That's a 10-20x improvement for the initial response. It's real, it's measurable, and for certain operations it's transformative.
But here's what the marketing glosses over: the edge is not a full server environment. It's something fundamentally different.
Traditional Node.js runs in a full operating system process. It has access to the filesystem, it can open TCP connections, it can spawn child processes, it can read environment variables as a stream, it can do essentially anything a Linux process can do.
Edge functions don't run on Node.js. They run on V8 isolates — the same JavaScript engine that powers Chrome, but stripped down to its core. Think of a V8 isolate as a lightweight sandbox:
// This works in Node.js but NOT at the edge
import fs from "fs";
import { createConnection } from "net";
import { execSync } from "child_process";
const file = fs.readFileSync("/etc/hosts"); // ❌ No filesystem
const conn = createConnection({ port: 5432 }); // ❌ No raw TCP
const result = execSync("ls -la"); // ❌ No child processes
process.env.DATABASE_URL; // ⚠️ Available but static, set at deploy timeWhat you DO have at the edge is the Web API surface — the same APIs available in a browser:
// These all work at the edge
const response = await fetch("https://api.example.com/data");
const url = new URL(request.url);
const headers = new Headers({ "Content-Type": "application/json" });
const encoder = new TextEncoder();
const encoded = encoder.encode("hello");
const hash = await crypto.subtle.digest("SHA-256", encoded);
const id = crypto.randomUUID();
// Web Streams API
const stream = new ReadableStream({
start(controller) {
controller.enqueue("chunk 1");
controller.enqueue("chunk 2");
controller.close();
},
});
// Cache API
const cache = caches.default;
await cache.put(request, response.clone());The constraints are real and hard:
await fetch() doesn't count, but JSON.parse() on a 5MB payload does)This isn't Node.js on a CDN. It's a different runtime with a different mental model.
You've probably heard that edge functions have "zero cold starts." This is... mostly true, and the comparison is genuinely dramatic.
A traditional container-based serverless function (AWS Lambda, Google Cloud Functions) works like this:
Step 2-5 is the cold start. For a Node.js Lambda, this is typically 200-500ms. For a Java Lambda, it can be 2-5 seconds. For a .NET Lambda, 500ms-1.5s.
V8 isolates work differently:
Step 2-3 takes under 5ms. Often under 1ms. The isolate is not a container — there's no OS to boot, no runtime to initialize. V8 creates a fresh isolate in microseconds. The phrase "zero cold start" is marketing-speak, but the reality (sub-5ms startup) is close enough to zero that it doesn't matter for most use cases.
But here's when cold starts do still bite you at the edge:
Large bundles. If your edge function pulls in 2MB of dependencies, that code still needs to be loaded and parsed. I learned this the hard way when I bundled a validation library and a date formatting library into an edge middleware. The cold start went from 2ms to 40ms. Still fast, but not "zero."
Rare locations. Edge providers have hundreds of PoPs, but not all PoPs keep your code warm. If you get one request per hour from Nairobi, that isolate gets recycled between requests. The next request pays the startup cost again.
Multiple isolates per request. If your edge function calls another edge function (or if middleware and an API route are both edge), you might be spinning up multiple isolates for one user request.
The practical advice: keep your edge function bundles small. Import only what you need. Tree-shake aggressively. The smaller the bundle, the faster the cold start, the more the "zero cold start" promise holds up.
// ❌ Don't do this at the edge
import dayjs from "dayjs";
import * as yup from "yup";
import lodash from "lodash";
// ✅ Do this instead — use built-in APIs
const date = new Date().toISOString();
const isValid = typeof input === "string" && input.length < 200;
const unique = [...new Set(items)];After experimenting extensively, I've found a clear pattern: edge functions excel when you need to make a fast decision about a request before it reaches your origin server. They're gatekeepers, routers, and transformers — not application servers.
This is the killer use case. The request hits the nearest edge node, which already knows where the user is. No API call needed, no IP lookup database — the platform provides the geo data:
// middleware.ts — runs at the edge on every request
import { NextRequest, NextResponse } from "next/server";
export const config = {
matcher: ["/", "/shop/:path*"],
};
export function middleware(request: NextRequest) {
const country = request.geo?.country ?? "US";
const city = request.geo?.city ?? "Unknown";
const region = request.geo?.region ?? "Unknown";
// Redirect to country-specific store
if (request.nextUrl.pathname === "/shop") {
const storeMap: Record<string, string> = {
DE: "/shop/eu",
FR: "/shop/eu",
GB: "/shop/uk",
JP: "/shop/jp",
TR: "/shop/tr",
};
const storePath = storeMap[country] ?? "/shop/us";
if (request.nextUrl.pathname !== storePath) {
return NextResponse.redirect(new URL(storePath, request.url));
}
}
// Add geo headers for downstream use
const response = NextResponse.next();
response.headers.set("x-user-country", country);
response.headers.set("x-user-city", city);
response.headers.set("x-user-region", region);
return response;
}This runs in under 5ms, right next to the user. The alternative — sending the request all the way to your origin server just to do an IP lookup and redirect back — would cost 100-300ms for users far from your origin.
Client-side A/B testing causes the dreaded "flash of original content" — the user sees version A for a split second before JavaScript swaps in version B. At the edge, you can assign the variant before the page even starts rendering:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Check if user already has a variant assignment
const existingVariant = request.cookies.get("ab-variant")?.value;
if (existingVariant) {
// Rewrite to the correct variant page
const url = request.nextUrl.clone();
url.pathname = `/variants/${existingVariant}${url.pathname}`;
return NextResponse.rewrite(url);
}
// Assign a new variant (50/50 split)
const variant = Math.random() < 0.5 ? "control" : "treatment";
const url = request.nextUrl.clone();
url.pathname = `/variants/${variant}${url.pathname}`;
const response = NextResponse.rewrite(url);
response.cookies.set("ab-variant", variant, {
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
sameSite: "lax",
});
return response;
}The user never sees a flicker because the rewrite happens at the network level. The browser doesn't even know it was an A/B test — it just receives the variant page directly.
If your auth uses JWTs (and you're not doing database session lookups), the edge is perfect. JWT verification is pure crypto — no database needed:
import { jwtVerify, importSPKI } from "jose";
import { NextRequest, NextResponse } from "next/server";
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`;
export async function middleware(request: NextRequest) {
const token = request.cookies.get("session-token")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
try {
const key = await importSPKI(PUBLIC_KEY, "RS256");
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
});
// Pass user info downstream as headers
const response = NextResponse.next();
response.headers.set("x-user-id", payload.sub as string);
response.headers.set("x-user-role", payload.role as string);
return response;
} catch {
// Token expired or invalid
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("session-token");
return response;
}
}This pattern is powerful: the edge middleware verifies the token and passes user information to your origin as trusted headers. Your API routes don't need to verify the token again — they just read request.headers.get("x-user-id").
Edge functions can block unwanted traffic before it ever reaches your origin:
import { NextRequest, NextResponse } from "next/server";
// Simple in-memory rate limiter (per edge location)
const rateLimitMap = new Map<string, { count: number; timestamp: number }>();
export function middleware(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for")?.split(",").pop()?.trim()
?? "unknown";
const ua = request.headers.get("user-agent") ?? "";
// Block known bad bots
const badBots = ["AhrefsBot", "SemrushBot", "MJ12bot", "DotBot"];
if (badBots.some((bot) => ua.includes(bot))) {
return new NextResponse("Forbidden", { status: 403 });
}
// Simple rate limiting
const now = Date.now();
const windowMs = 60_000; // 1 minute
const maxRequests = 100;
const entry = rateLimitMap.get(ip);
if (entry && now - entry.timestamp < windowMs) {
entry.count++;
if (entry.count > maxRequests) {
return new NextResponse("Too Many Requests", {
status: 429,
headers: { "Retry-After": "60" },
});
}
} else {
rateLimitMap.set(ip, { count: 1, timestamp: now });
}
// Periodic cleanup to prevent memory leak
if (rateLimitMap.size > 10_000) {
const cutoff = now - windowMs;
for (const [key, val] of rateLimitMap) {
if (val.timestamp < cutoff) rateLimitMap.delete(key);
}
}
return NextResponse.next();
}One caveat: the rate limit map above is per-isolate, per-location. If you have 300 edge locations, each one has its own map. For strict rate limiting, you need a distributed store like Upstash Redis or Cloudflare Durable Objects. But for rough abuse prevention, per-location limits work surprisingly well.
Edge functions are excellent at transforming requests before they reach your origin:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const url = request.nextUrl;
// Device-based content negotiation
const ua = request.headers.get("user-agent") ?? "";
const isMobile = /mobile|android|iphone/i.test(ua);
response.headers.set("x-device-type", isMobile ? "mobile" : "desktop");
// Feature flags from cookie
const flags = request.cookies.get("feature-flags")?.value;
if (flags) {
response.headers.set("x-feature-flags", flags);
}
// Locale detection for i18n
const acceptLanguage = request.headers.get("accept-language") ?? "en";
const preferredLocale = acceptLanguage.split(",")[0]?.split("-")[0] ?? "en";
const supportedLocales = [
"en", "tr", "de", "fr", "es", "pt", "ja", "ko", "it",
"nl", "ru", "pl", "uk", "sv", "cs", "ar", "hi", "zh",
];
const locale = supportedLocales.includes(preferredLocale)
? preferredLocale
: "en";
if (!url.pathname.startsWith(`/${locale}`) && !url.pathname.startsWith("/api")) {
return NextResponse.redirect(new URL(`/${locale}${url.pathname}`, request.url));
}
return response;
}This is the section the marketing pages skip. I've hit every one of these walls.
This is the big one. Traditional databases (PostgreSQL, MySQL) use persistent TCP connections. A Node.js server opens a connection pool at startup and reuses those connections across requests. Efficient, proven, well-understood.
Edge functions can't do this. Each isolate is ephemeral. There's no "startup" phase where you open connections. Even if you could open a connection, the isolate might be recycled after one request, wasting the connection setup time.
// ❌ This pattern fundamentally doesn't work at the edge
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // Connection pool of 10
});
// Each edge invocation would:
// 1. Create a new Pool (can't reuse across invocations reliably)
// 2. Open a TCP connection to your database (which is in us-east-1, not at the edge)
// 3. Do TLS handshake with the database
// 4. Run the query
// 5. Discard the connection when the isolate recycles
// Even with connection pooling services like PgBouncer,
// you're still paying the network latency from edge → origin databaseThe database round-trip problem is fundamental. Your database is in one region. Your edge function is in 300 regions. Every database query from the edge has to travel from the edge location to the database region and back. For a user in Tokyo hitting a Tokyo edge node, but your database is in Virginia:
Edge function in Tokyo
→ Query to PostgreSQL in Virginia: ~140ms round trip
→ Second query: ~140ms more
→ Total: 280ms just for two queries
Node.js function in Virginia (same region as DB)
→ Query to PostgreSQL: ~1ms round trip
→ Second query: ~1ms more
→ Total: 2ms for two queries
The edge function is 140x slower for database operations in this scenario. It doesn't matter that the edge function started faster — the database round trips dominate everything.
This is why HTTP-based database proxies exist (Neon's serverless driver, PlanetScale's fetch-based driver, Supabase's REST API). They work, but they're still making HTTP requests to a database in a single region. They solve the "can't use TCP" problem but not the "database is far away" problem.
// ✅ This works at the edge (HTTP-based database access)
// But it's still slow if the database is far from the edge node
import { neon } from "@neondatabase/serverless";
export const runtime = "edge";
export async function GET(request: Request) {
const sql = neon(process.env.DATABASE_URL!);
// This makes an HTTP request to your Neon database
// Works, but latency depends on distance to the database region
const posts = await sql`SELECT * FROM posts WHERE published = true LIMIT 10`;
return Response.json(posts);
}Edge functions have CPU time limits, typically 10-50ms of actual compute time. Wall clock time is more generous (usually 30 seconds), but CPU-intensive operations will hit the limit fast:
// ❌ These will exceed CPU time limits at the edge
export const runtime = "edge";
export async function POST(request: Request) {
const data = await request.json();
// Image processing — CPU intensive
// (Also can't use sharp because it's a native module)
const processed = heavyImageProcessing(data.image);
// PDF generation — CPU intensive + needs Node.js APIs
const pdf = generatePDF(data.content);
// Large data transformation
const result = data.items // 100,000 items
.map(transform)
.filter(validate)
.sort(compare)
.reduce(aggregate, {});
return Response.json(result);
}If your function needs more than a few milliseconds of CPU time, it belongs on a regional Node.js server. Period.
This one catches people off guard. A surprising number of npm packages depend on Node.js built-in modules:
// ❌ These packages won't work at the edge
import bcrypt from "bcrypt"; // Native C++ binding
import sharp from "sharp"; // Native C++ binding
import puppeteer from "puppeteer"; // Needs filesystem + child_process
import nodemailer from "nodemailer"; // Needs net module
import { readFile } from "fs/promises"; // Node.js filesystem API
import mongoose from "mongoose"; // TCP connections + Node.js APIs
// ✅ Edge-compatible alternatives
import { hashSync } from "bcryptjs"; // Pure JS implementation (slower)
// For images: use a separate service or API
// For email: use an HTTP-based email API (Resend, SendGrid REST)
// For database: use HTTP-based clientsBefore moving anything to the edge, check every dependency. One require("fs") buried three levels deep in your dependency tree will crash your edge function at runtime — not at build time. You'll deploy, everything looks fine, then the first request hits that code path and you get a cryptic error.
Edge platforms have strict bundle size limits:
This sounds like plenty until you import a UI component library, a validation library, and a date library. I once had an edge middleware that ballooned to 3.5MB because I imported from a barrel file that pulled in the entire @/components directory.
// ❌ Barrel file imports can pull in way too much
import { validateEmail } from "@/lib/utils";
// If utils.ts re-exports from 20 other modules, all of them get bundled
// ✅ Import directly from the source
import { validateEmail } from "@/lib/validators/email";Edge functions can do streaming responses (Web Streams API), but long-lived WebSocket connections are a different story. While some platforms support WebSockets at the edge (Cloudflare Workers, Deno Deploy), the ephemeral nature of edge functions makes them a poor fit for stateful, long-lived connections.
Next.js makes it straightforward to opt into the edge runtime on a per-route basis. You don't have to go all-in — you pick exactly which routes run at the edge.
Next.js middleware always runs at the edge. This is by design — middleware intercepts every matching request, so it needs to be fast and globally distributed:
// middleware.ts — always runs at the edge, no opt-in needed
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// This runs before every matching request
// Keep it fast — no database calls, no heavy computation
return NextResponse.next();
}
export const config = {
// Only run on specific paths
matcher: [
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
],
};Any route handler can opt into the edge runtime:
// app/api/hello/route.ts
export const runtime = "edge"; // This one line changes the runtime
export async function GET(request: Request) {
return Response.json({
message: "Hello from the edge",
region: process.env.VERCEL_REGION ?? "unknown",
timestamp: Date.now(),
});
}Even entire pages can render at the edge, though I'd think carefully before doing this:
// app/dashboard/page.tsx
export const runtime = "edge";
export default async function DashboardPage() {
// Remember: no Node.js APIs here
// Any data fetching must use fetch() or edge-compatible clients
const data = await fetch("https://api.example.com/dashboard", {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
next: { revalidate: 60 },
}).then((r) => r.json());
return (
<main>
<h1>Dashboard</h1>
{/* render data */}
</main>
);
}Here's a practical reference of what you can and can't use:
// ✅ Available at the edge
fetch() // HTTP requests
Request / Response // Web standard request/response
Headers // HTTP headers
URL / URLSearchParams // URL parsing
TextEncoder / TextDecoder // String encoding
crypto.subtle // Crypto operations (signing, hashing)
crypto.randomUUID() // UUID generation
crypto.getRandomValues() // Cryptographic random numbers
structuredClone() // Deep cloning
atob() / btoa() // Base64 encoding/decoding
setTimeout() / setInterval() // Timers (but remember CPU limits)
console.log() // Logging
ReadableStream / WritableStream // Streaming
AbortController / AbortSignal // Request cancellation
URLPattern // URL pattern matching
// ❌ NOT available at the edge
require() // CommonJS (use import)
fs / path / os // Node.js built-in modules
process.exit() // Process control
Buffer // Use Uint8Array instead
__dirname / __filename // Use import.meta.url
setImmediate() // Not a web standardI want to go deeper on authentication because it's one of the most impactful edge use cases, but it's also easy to get wrong.
The pattern that works is: verify the token at the edge, pass trusted claims downstream, never touch the database in middleware.
// lib/edge-auth.ts — Edge-compatible auth utilities
import { jwtVerify, SignJWT, importSPKI, importPKCS8 } from "jose";
const PUBLIC_KEY_PEM = process.env.JWT_PUBLIC_KEY!;
const ISSUER = "https://auth.myapp.com";
const AUDIENCE = "https://myapp.com";
export interface TokenPayload {
sub: string;
email: string;
role: "user" | "admin" | "moderator";
iat: number;
exp: number;
}
export async function verifyToken(token: string): Promise<TokenPayload | null> {
try {
const publicKey = await importSPKI(PUBLIC_KEY_PEM, "RS256");
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
issuer: ISSUER,
audience: AUDIENCE,
clockTolerance: 30, // 30 seconds of clock skew tolerance
});
return payload as unknown as TokenPayload;
} catch {
return null;
}
}
export function isTokenExpiringSoon(payload: TokenPayload): boolean {
const now = Math.floor(Date.now() / 1000);
const fiveMinutes = 5 * 60;
return payload.exp - now < fiveMinutes;
}// middleware.ts — The auth middleware
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, isTokenExpiringSoon } from "./lib/edge-auth";
const PUBLIC_PATHS = ["/", "/login", "/register", "/api/auth/login"];
const ADMIN_PATHS = ["/admin"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip auth for public paths
if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith("/api/public"))) {
return NextResponse.next();
}
// Extract token
const token = request.cookies.get("auth-token")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Verify token (pure crypto — no database call)
const payload = await verifyToken(token);
if (!payload) {
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("auth-token");
return response;
}
// Role-based access control
if (ADMIN_PATHS.some((p) => pathname.startsWith(p)) && payload.role !== "admin") {
return NextResponse.redirect(new URL("/unauthorized", request.url));
}
// Pass verified user info to the origin as trusted headers
const response = NextResponse.next();
response.headers.set("x-user-id", payload.sub);
response.headers.set("x-user-email", payload.email);
response.headers.set("x-user-role", payload.role);
// Signal if token needs refresh
if (isTokenExpiringSoon(payload)) {
response.headers.set("x-token-refresh", "true");
}
return response;
}// app/api/profile/route.ts — Origin server reads trusted headers
export async function GET(request: Request) {
// These headers were set by edge middleware after JWT verification
// They're trusted because they come from our own infrastructure
const userId = request.headers.get("x-user-id");
const userRole = request.headers.get("x-user-role");
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// Now we can hit the database — we're on the origin server,
// right next to the database, with a connection pool
const user = await db.user.findUnique({ where: { id: userId } });
return Response.json(user);
}The key insight: the edge does the fast part (crypto verification), and the origin does the slow part (database queries). Each runs where it's most efficient.
One important caveat: this only works for JWTs. If your auth system requires a database lookup on every request (like session-based auth with a session ID cookie), the edge can't help — you'd still need to call the database, which means a round trip to the origin region.
Caching at the edge is where things get interesting. Edge nodes can cache responses, which means subsequent requests to the same URL get served directly from the edge without hitting your origin at all.
// app/api/products/route.ts
export const runtime = "edge";
export async function GET(request: Request) {
const url = new URL(request.url);
const category = url.searchParams.get("category") ?? "all";
const products = await fetch(
`${process.env.ORIGIN_API}/products?category=${category}`,
).then((r) => r.json());
return Response.json(products, {
headers: {
// Cache on CDN for 60 seconds
// Serve stale while revalidating for up to 5 minutes
// Client can cache for 10 seconds
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300, max-age=10",
// Vary by these headers so different variants get different cache entries
Vary: "Accept-Language, Accept-Encoding",
// CDN-specific cache tag for targeted invalidation
"Cache-Tag": `products,category-${category}`,
},
});
}The stale-while-revalidate pattern is particularly powerful at the edge. Here's what happens:
Your users almost always get a cached response. The freshness tradeoff is explicit and tunable.
Vercel's Edge Config (and similar services from other platforms) lets you store key-value configuration that's replicated to every edge location. This is incredibly useful for feature flags, redirect rules, and A/B test configuration that you want to update without redeploying:
import { get } from "@vercel/edge-config";
import { NextRequest, NextResponse } from "next/server";
export async function middleware(request: NextRequest) {
// Edge Config reads are extremely fast (~1ms) because
// the data is replicated to every edge location
const maintenanceMode = await get<boolean>("maintenance_mode");
if (maintenanceMode) {
return NextResponse.rewrite(new URL("/maintenance", request.url));
}
// Feature flags
const features = await get<Record<string, boolean>>("feature_flags");
if (features?.["new_pricing_page"] && request.nextUrl.pathname === "/pricing") {
return NextResponse.rewrite(new URL("/pricing-v2", request.url));
}
// Dynamic redirects (update redirects without redeploy)
const redirects = await get<Array<{ from: string; to: string; permanent: boolean }>>(
"redirects",
);
if (redirects) {
const match = redirects.find((r) => r.from === request.nextUrl.pathname);
if (match) {
return NextResponse.redirect(
new URL(match.to, request.url),
match.permanent ? 308 : 307,
);
}
}
return NextResponse.next();
}This is a genuine game-changer. Before Edge Config, changing a feature flag meant a code change and redeploy. Now you update a JSON value in a dashboard and it propagates globally in seconds.
Let's do the honest math instead of the marketing math. I'll compare three architectures for a typical API endpoint that needs to query a database:
Architecture A: Traditional Regional Node.js
User in Tokyo → Origin in Virginia: 140ms
+ DB query 1 (same region): 2ms
+ DB query 2 (same region): 2ms
+ Processing: 5ms
= Total: ~149ms
Architecture B: Edge Function with HTTP Database
User in Tokyo → Edge in Tokyo: 5ms
+ DB query 1 (HTTP to Virginia): 145ms
+ DB query 2 (HTTP to Virginia): 145ms
+ Processing: 3ms
= Total: ~298ms ← SLOWER than regional
Architecture C: Edge Function with Regional Database (read replica)
User in Tokyo → Edge in Tokyo: 5ms
+ DB query 1 (HTTP to Tokyo replica): 8ms
+ DB query 2 (HTTP to Tokyo replica): 8ms
+ Processing: 3ms
= Total: ~24ms ← Fastest, but requires multi-region DB
Architecture D: Edge for Auth + Regional for Data
User in Tokyo → Edge middleware in Tokyo: 5ms (JWT verify)
→ Origin in Virginia: 140ms
+ DB query 1 (same region): 2ms
+ DB query 2 (same region): 2ms
+ Processing: 5ms
= Total: ~154ms
(But auth is already verified — origin doesn't need to re-verify)
(And unauthorized requests are blocked at the edge — never reach origin)
The takeaways:
Architecture D is what I use for most projects. The edge handles what it's good at (fast decisions, auth, routing), and the regional Node.js server handles what it's good at (database queries, heavy computation).
The math completely flips when there's no database involved:
Redirect (edge):
User in Tokyo → Edge in Tokyo → redirect response: ~5ms
Redirect (regional):
User in Tokyo → Origin in Virginia → redirect response: ~280ms
Static API response (edge + cache):
User in Tokyo → Edge in Tokyo → cached response: ~5ms
Static API response (regional):
User in Tokyo → Origin in Virginia → response: ~280ms
Bot blocking (edge):
Bad bot in anywhere → Edge (nearest) → 403 response: ~5ms
(Bot never reaches your origin server)
Bot blocking (regional):
Bad bot in anywhere → Origin in Virginia → 403 response: ~280ms
(Bot still consumed origin resources)
For operations that don't need a database, the edge is 20-50x faster. This isn't marketing — it's physics.
After a year of working with edge functions in production, here's the flowchart I use for every new endpoint or piece of logic:
If it imports fs, net, child_process, or any native module — Node.js regional. No debate.
If yes, and you don't have read replicas near your users — Node.js regional (in the same region as your database). The database round trips will dominate.
If yes, and you have globally distributed read replicas — Edge can work, using HTTP-based database clients.
If yes — Edge. This is the sweet spot. You're making a fast decision that determines what happens to the request before it reaches the origin.
If yes — Edge with proper Cache-Control headers. Even if the first request goes to your origin, subsequent requests serve from edge cache.
If it involves significant computation (image processing, PDF generation, large data transforms) — Node.js regional.
If it's a background job or webhook — Node.js regional. Nobody's waiting on it. If it's a user-facing request where every ms matters — Edge, if it meets the other criteria.
// ✅ PERFECT for edge
// - Middleware (auth, redirects, rewrites, headers)
// - Geolocation logic
// - A/B test assignment
// - Bot detection / WAF rules
// - Cache-friendly API responses
// - Feature flag checks
// - CORS preflight responses
// - Static data transformations (no DB)
// - Webhook signature verification
// ❌ KEEP on Node.js regional
// - Database CRUD operations
// - File uploads / processing
// - Image manipulation
// - PDF generation
// - Email sending (use HTTP API, but still regional)
// - WebSocket servers
// - Background jobs / queues
// - Anything using native npm packages
// - SSR pages with database queries
// - GraphQL resolvers that hit databases
// 🤔 IT DEPENDS
// - Authentication (edge for JWT, regional for session-DB)
// - API routes (edge if no DB, regional if DB)
// - Server-rendered pages (edge if data comes from cache/fetch, regional if DB)
// - Real-time features (edge for initial auth, regional for persistent connections)For this site, here's the breakdown:
Edge (middleware):
Node.js regional:
Static (no runtime at all):
The best runtime is often no runtime. If you can pre-render something at build time and serve it as a static asset, that will always be faster than any edge function. The edge is for the things that genuinely need to be dynamic on every request.
Edge functions are not a replacement for traditional servers. They're a complement. They're an additional tool in your architecture toolbox — one that's incredibly powerful for the right use cases and actively harmful for the wrong ones.
The heuristic I keep coming back to: if your function needs to reach out to a database in a single region, putting the function at the edge doesn't help — it hurts. You've just added a hop. The function runs faster, but then it spends 100ms+ reaching back to the database. Net result: slower than running everything in one region.
But for decisions that can be made with only the information in the request itself — geolocation, cookies, headers, JWTs — the edge is unbeatable. Those 5ms edge responses are not synthetic benchmarks. They're real, and your users feel the difference.
Don't move everything to the edge. Don't keep everything off the edge. Put each piece of logic where the physics favors it.