Edge runtime, V8 isolates, el mito del cold start, geo-routing, A/B testing, auth en el edge y por qué moví algunas cosas de vuelta a Node.js. Una mirada equilibrada al edge computing.
La palabra "edge" se usa mucho. Vercel la dice. Cloudflare la dice. Deno la dice. Cada charla de conferencia sobre rendimiento web inevitablemente menciona "correr en el edge" como si fuera un hechizo mágico que hace tu app rápida.
Yo me lo creí. Moví middleware, rutas de API e incluso algo de lógica de renderizado al edge runtime. Algunos de esos movimientos fueron brillantes. Otros los moví silenciosamente de vuelta a Node.js tres semanas después, tras depurar errores de connection pool a las 2 AM.
Este post es la versión equilibrada de esa historia — qué es realmente el edge, dónde genuinamente brilla, dónde absolutamente no funciona, y cómo decido qué runtime usar para cada parte de mi aplicación.
Empecemos por la geografía. Cuando alguien visita tu sitio web, su solicitud viaja desde su dispositivo, a través de su ISP, por internet hasta tu servidor, se procesa, y la respuesta viaja todo el camino de vuelta. Si tu servidor está en us-east-1 (Virginia) y tu usuario está en Tokio, ese viaje de ida y vuelta cubre aproximadamente 14,000 km. A la velocidad de la luz por fibra óptica, eso son unos 70ms solo por la física — en una dirección. Agrega resolución DNS, handshake TLS y cualquier tiempo de procesamiento, y fácilmente estás viendo 200-400ms antes de que tu usuario vea un solo byte.
El "edge" significa ejecutar tu código en servidores distribuidos globalmente — los mismos nodos CDN que siempre han servido assets estáticos, pero ahora también pueden ejecutar tu lógica. En lugar de un solo servidor de origen en Virginia, tu código corre en más de 300 ubicaciones en todo el mundo. Un usuario en Tokio llega a un servidor en Tokio. Un usuario en París llega a un servidor en París.
Las matemáticas de latencia son simples y convincentes:
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
Eso es una mejora de 10-20x para la respuesta inicial. Es real, es medible, y para ciertas operaciones es transformador.
Pero esto es lo que el marketing omite: el edge no es un entorno de servidor completo. Es algo fundamentalmente diferente.
Node.js tradicional corre en un proceso completo del sistema operativo. Tiene acceso al sistema de archivos, puede abrir conexiones TCP, puede lanzar procesos hijos, puede leer variables de entorno como un stream, puede hacer esencialmente cualquier cosa que un proceso Linux pueda hacer.
Las edge functions no corren en Node.js. Corren en V8 isolates — el mismo motor JavaScript que impulsa Chrome, pero reducido a lo esencial. Piensa en un V8 isolate como un sandbox liviano:
// 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 timeLo que SÍ tienes en el edge es la superficie de Web APIs — las mismas APIs disponibles en un navegador:
// 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());Las restricciones son reales y duras:
await fetch() no cuenta, pero JSON.parse() en un payload de 5MB sí)Esto no es Node.js en un CDN. Es un runtime diferente con un modelo mental diferente.
Probablemente hayas escuchado que las edge functions tienen "cero cold starts". Esto es... mayormente verdad, y la comparación es genuinamente dramática.
Una función serverless tradicional basada en contenedores (AWS Lambda, Google Cloud Functions) funciona así:
Los pasos 2-5 son el cold start. Para un Lambda de Node.js, esto es típicamente 200-500ms. Para un Lambda de Java, puede ser 2-5 segundos. Para un Lambda de .NET, 500ms-1.5s.
Los V8 isolates funcionan diferente:
Los pasos 2-3 toman menos de 5ms. A menudo menos de 1ms. El isolate no es un contenedor — no hay SO que arrancar, no hay runtime que inicializar. V8 crea un isolate fresco en microsegundos. La frase "cero cold start" es lenguaje de marketing, pero la realidad (startup sub-5ms) está lo suficientemente cerca de cero como para que no importe en la mayoría de casos de uso.
Pero hay casos donde los cold starts sí te muerden en el edge:
Bundles grandes. Si tu edge function trae 2MB de dependencias, ese código igual necesita ser cargado y parseado. Aprendí esto por las malas cuando incluí una librería de validación y una librería de formateo de fechas en un edge middleware. El cold start pasó de 2ms a 40ms. Sigue siendo rápido, pero no es "cero".
Ubicaciones poco frecuentes. Los proveedores de edge tienen cientos de PoPs, pero no todos los PoPs mantienen tu código caliente. Si recibes una solicitud por hora desde Nairobi, ese isolate se recicla entre solicitudes. La siguiente solicitud paga el costo de inicio de nuevo.
Múltiples isolates por solicitud. Si tu edge function llama a otra edge function (o si el middleware y una ruta de API ambos son edge), podrías estar levantando múltiples isolates para una sola solicitud de usuario.
El consejo práctico: mantén los bundles de tus edge functions pequeños. Importa solo lo que necesites. Haz tree-shake agresivo. Cuanto más pequeño el bundle, más rápido el cold start, y más se cumple la promesa de "cero cold start".
// ❌ 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)];Después de experimentar extensamente, encontré un patrón claro: las edge functions brillan cuando necesitas tomar una decisión rápida sobre una solicitud antes de que llegue a tu servidor de origen. Son porteros, routers y transformadores — no servidores de aplicación.
Este es el caso de uso estrella. La solicitud llega al nodo edge más cercano, que ya sabe dónde está el usuario. No se necesita llamada a una API, no hay base de datos de lookup de IPs — la plataforma proporciona los datos de geolocalización:
// 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;
}Esto corre en menos de 5ms, justo al lado del usuario. La alternativa — enviar la solicitud hasta tu servidor de origen solo para hacer un lookup de IP y redirigir de vuelta — costaría 100-300ms para usuarios lejos de tu origen.
El A/B testing del lado del cliente causa el temido "flash de contenido original" — el usuario ve la versión A por una fracción de segundo antes de que JavaScript intercambie la versión B. En el edge, puedes asignar la variante antes de que la página siquiera empiece a renderizar:
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;
}El usuario nunca ve un parpadeo porque el rewrite sucede a nivel de red. El navegador ni siquiera sabe que fue un test A/B — simplemente recibe la página de la variante directamente.
Si tu auth usa JWTs (y no estás haciendo lookups de sesión en la base de datos), el edge es perfecto. La verificación de JWT es pura criptografía — no se necesita base de datos:
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;
}
}Este patrón es poderoso: el edge middleware verifica el token y pasa la información del usuario a tu origen como headers confiables. Tus rutas de API no necesitan verificar el token de nuevo — simplemente leen request.headers.get("x-user-id").
Las edge functions pueden bloquear tráfico no deseado antes de que llegue a tu origen:
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();
}Una advertencia: el mapa de rate limit de arriba es por isolate, por ubicación. Si tienes 300 ubicaciones edge, cada una tiene su propio mapa. Para rate limiting estricto, necesitas un almacén distribuido como Upstash Redis o Cloudflare Durable Objects. Pero para prevención básica de abuso, los límites por ubicación funcionan sorprendentemente bien.
Las edge functions son excelentes para transformar solicitudes antes de que lleguen a tu origen:
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;
}Esta es la sección que las páginas de marketing se saltan. Yo me he chocado con cada uno de estos muros.
Esta es la grande. Las bases de datos tradicionales (PostgreSQL, MySQL) usan conexiones TCP persistentes. Un servidor Node.js abre un pool de conexiones al inicio y reutiliza esas conexiones entre solicitudes. Eficiente, probado, bien entendido.
Las edge functions no pueden hacer esto. Cada isolate es efímero. No hay una fase de "inicio" donde abres conexiones. Incluso si pudieras abrir una conexión, el isolate podría reciclarse después de una solicitud, desperdiciando el tiempo de configuración de la conexión.
// ❌ 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 databaseEl problema del round-trip a la base de datos es fundamental. Tu base de datos está en una región. Tu edge function está en 300 regiones. Cada consulta a la base de datos desde el edge tiene que viajar desde la ubicación edge hasta la región de la base de datos y volver. Para un usuario en Tokio que llega a un nodo edge en Tokio, pero tu base de datos está en 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
La edge function es 140x más lenta para operaciones de base de datos en este escenario. No importa que la edge function haya arrancado más rápido — los round trips a la base de datos dominan todo.
Por eso existen los proxies de base de datos basados en HTTP (el driver serverless de Neon, el driver basado en fetch de PlanetScale, la REST API de Supabase). Funcionan, pero siguen haciendo solicitudes HTTP a una base de datos en una sola región. Resuelven el problema de "no puedo usar TCP" pero no el problema de "la base de datos está lejos".
// ✅ 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);
}Las edge functions tienen límites de tiempo de CPU, típicamente 10-50ms de tiempo de cómputo real. El tiempo de reloj es más generoso (usualmente 30 segundos), pero las operaciones intensivas en CPU llegarán al límite rápido:
// ❌ 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);
}Si tu función necesita más de unos pocos milisegundos de tiempo de CPU, pertenece a un servidor regional de Node.js. Punto.
Esta atrapa a la gente desprevenida. Una cantidad sorprendente de paquetes npm dependen de módulos built-in de Node.js:
// ❌ 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 clientsAntes de mover cualquier cosa al edge, revisa cada dependencia. Un solo require("fs") enterrado tres niveles de profundidad en tu árbol de dependencias va a crashear tu edge function en runtime — no en build time. Vas a hacer deploy, todo se ve bien, y entonces la primera solicitud toca esa ruta de código y recibes un error críptico.
Las plataformas edge tienen límites estrictos de tamaño de bundle:
Esto suena como suficiente hasta que haces import de una librería de componentes UI, una librería de validación y una librería de fechas. Una vez tuve un edge middleware que se infló a 3.5MB porque importé desde un barrel file que trajo todo el directorio @/components.
// ❌ 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";Las edge functions pueden hacer streaming responses (Web Streams API), pero las conexiones WebSocket de larga duración son otra historia. Aunque algunas plataformas soportan WebSockets en el edge (Cloudflare Workers, Deno Deploy), la naturaleza efímera de las edge functions las hace una mala opción para conexiones stateful de larga duración.
Next.js hace que sea sencillo optar por el edge runtime ruta por ruta. No tienes que ir all-in — eliges exactamente qué rutas corren en el edge.
El middleware de Next.js siempre corre en el edge. Es por diseño — el middleware intercepta cada solicitud que coincida, así que necesita ser rápido y estar distribuido globalmente:
// 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).*)",
],
};Cualquier route handler puede optar por el 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(),
});
}Incluso páginas completas pueden renderizar en el edge, aunque yo lo pensaría bien antes de hacerlo:
// 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>
);
}Aquí tienes una referencia práctica de lo que puedes y no puedes usar:
// ✅ 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 standardQuiero profundizar en la autenticación porque es uno de los casos de uso más impactantes del edge, pero también es fácil hacerlo mal.
El patrón que funciona es: verificar el token en el edge, pasar claims confiables hacia abajo, nunca tocar la base de datos en el 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);
}La clave: el edge hace la parte rápida (verificación criptográfica), y el origen hace la parte lenta (consultas a la base de datos). Cada uno corre donde es más eficiente.
Una advertencia importante: esto solo funciona para JWTs. Si tu sistema de auth requiere un lookup en la base de datos en cada solicitud (como auth basado en sesiones con una cookie de session ID), el edge no te ayuda — igual necesitarías llamar a la base de datos, lo que significa un round trip a la región de origen.
El caching en el edge es donde las cosas se ponen interesantes. Los nodos edge pueden cachear respuestas, lo que significa que solicitudes subsiguientes a la misma URL se sirven directamente desde el edge sin tocar tu origen en absoluto.
// 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}`,
},
});
}El patrón stale-while-revalidate es particularmente poderoso en el edge. Esto es lo que pasa:
Tus usuarios casi siempre reciben una respuesta cacheada. El tradeoff de frescura es explícito y configurable.
Vercel Edge Config (y servicios similares de otras plataformas) te permite almacenar configuración key-value que se replica en cada ubicación edge. Esto es increíblemente útil para feature flags, reglas de redirección y configuración de tests A/B que quieres actualizar sin redesplegar:
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();
}Esto es un verdadero game-changer. Antes de Edge Config, cambiar un feature flag significaba un cambio de código y redeploy. Ahora actualizas un valor JSON en un dashboard y se propaga globalmente en segundos.
Hagamos las cuentas honestas en lugar de las cuentas del marketing. Voy a comparar tres arquitecturas para un endpoint típico de API que necesita consultar una base de datos:
Arquitectura A: Node.js regional tradicional
User in Tokyo → Origin in Virginia: 140ms
+ DB query 1 (same region): 2ms
+ DB query 2 (same region): 2ms
+ Processing: 5ms
= Total: ~149ms
Arquitectura B: Edge Function con base de datos HTTP
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
Arquitectura C: Edge Function con base de datos regional (réplica de lectura)
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
Arquitectura D: Edge para auth + regional para datos
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)
Las conclusiones:
La Arquitectura D es la que uso para la mayoría de proyectos. El edge maneja lo que hace bien (decisiones rápidas, auth, routing), y el servidor regional de Node.js maneja lo que hace bien (consultas a la base de datos, cómputo pesado).
Las matemáticas cambian completamente cuando no hay base de datos involucrada:
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)
Para operaciones que no necesitan una base de datos, el edge es 20-50x más rápido. Esto no es marketing — es física.
Después de un año trabajando con edge functions en producción, este es el diagrama de flujo que uso para cada nuevo endpoint o pieza de lógica:
Si importa fs, net, child_process, o cualquier módulo nativo — Node.js regional. Sin debate.
Si sí, y no tienes réplicas de lectura cerca de tus usuarios — Node.js regional (en la misma región que tu base de datos). Los round trips a la base de datos dominarán.
Si sí, y tienes réplicas de lectura distribuidas globalmente — Edge puede funcionar, usando clientes de base de datos basados en HTTP.
Si sí — Edge. Este es el punto dulce. Estás tomando una decisión rápida que determina qué pasa con la solicitud antes de que llegue al origen.
Si sí — Edge con headers Cache-Control apropiados. Incluso si la primera solicitud va a tu origen, las solicitudes subsiguientes se sirven desde el caché del edge.
Si involucra cómputo significativo (procesamiento de imágenes, generación de PDFs, transformaciones grandes de datos) — Node.js regional.
Si es un job en segundo plano o un webhook — Node.js regional. Nadie lo está esperando. Si es una solicitud de cara al usuario donde cada ms importa — Edge, si cumple los otros criterios.
// ✅ 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)Para este sitio, así se desglosa:
Edge (middleware):
Node.js regional:
Estático (sin runtime en absoluto):
El mejor runtime frecuentemente es ningún runtime. Si puedes pre-renderizar algo en build time y servirlo como un asset estático, eso siempre será más rápido que cualquier edge function. El edge es para las cosas que genuinamente necesitan ser dinámicas en cada solicitud.
Las edge functions no son un reemplazo para los servidores tradicionales. Son un complemento. Son una herramienta adicional en tu caja de herramientas de arquitectura — una que es increíblemente poderosa para los casos de uso correctos y activamente dañina para los incorrectos.
La heurística a la que siempre vuelvo: si tu función necesita comunicarse con una base de datos en una sola región, poner la función en el edge no ayuda — perjudica. Solo agregaste un salto. La función arranca más rápido, pero luego gasta 100ms+ llegando de vuelta a la base de datos. Resultado neto: más lento que correr todo en una región.
Pero para decisiones que se pueden tomar solo con la información de la solicitud en sí — geolocalización, cookies, headers, JWTs — el edge es imbatible. Esas respuestas de 5ms en el edge no son benchmarks sintéticos. Son reales, y tus usuarios sienten la diferencia.
No muevas todo al edge. No mantengas todo fuera del edge. Pon cada pieza de lógica donde la física la favorece.