Edge Functions: Qué son, cuándo usarlas y cuándo no
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.
¿Qué es el Edge?#
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.
V8 Isolates vs Node.js#
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:
- Memoria: 128MB por isolate (Cloudflare Workers), 256MB en algunas plataformas
- Tiempo de CPU: 10-50ms de tiempo real de CPU (no tiempo de reloj —
await fetch()no cuenta, peroJSON.parse()en un payload de 5MB sí) - Sin módulos nativos: Cualquier cosa que necesite un binding C++ (bcrypt, sharp, canvas) queda fuera
- Sin conexiones persistentes: No puedes mantener abierta una conexión a la base de datos entre requests
- Límites de tamaño de bundle: Típicamente 1-5MB para el script completo del worker
Esto no es Node.js en un CDN. Es un runtime diferente con un modelo mental diferente.
Cold Starts: El mito y la realidad#
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í:
- Llega la solicitud
- La plataforma provisiona un contenedor (si no hay ninguno disponible)
- El contenedor arranca el SO
- El runtime se inicializa (Node.js, Python, etc.)
- Tu código se carga e inicializa
- La solicitud se procesa
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:
- Llega la solicitud
- La plataforma crea un nuevo V8 isolate (o reutiliza uno caliente)
- Tu código se carga (ya está compilado a bytecode en el momento del deploy)
- La solicitud se procesa
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)];Casos de uso perfectos para Edge Functions#
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.
1. Redirecciones basadas en geolocalizació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.
2. A/B Testing sin parpadeo en el cliente#
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.
3. Verificación de tokens de autenticación#
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").
4. Detección de bots y rate limiting#
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.
5. Reescritura de solicitudes y headers de personalización#
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;
}Dónde falla el Edge#
Esta es la sección que las páginas de marketing se saltan. Yo me he chocado con cada uno de estos muros.
1. Conexiones a bases de datos#
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);
}2. Tareas de larga duración#
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.
3. Dependencias exclusivas de Node.js#
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.
4. Tamaños de bundle grandes#
Las plataformas edge tienen límites estrictos de tamaño de bundle:
- Cloudflare Workers: 1MB (gratis), 5MB (de pago)
- Vercel Edge Functions: 4MB (comprimido)
- Deno Deploy: 20MB
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";5. Streaming y WebSockets#
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 Edge Runtime#
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.
Middleware (siempre 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).*)",
],
};Rutas de API en el Edge#
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(),
});
}Rutas de páginas en el Edge#
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>
);
}Qué está disponible en el Edge Runtime#
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 standardAuth en el Edge: El patrón completo#
Quiero 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.
Caching en el Edge#
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.
Cache-Control bien hecho#
// 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:
- Primera solicitud: El edge obtiene del origen, cachea la respuesta, la retorna
- Solicitudes dentro de 60 segundos: El edge sirve desde caché (0ms de latencia al origen)
- Solicitud entre 61-360 segundos: El edge sirve la versión cacheada stale inmediatamente, pero obtiene una versión fresca del origen en segundo plano
- Después de 360 segundos: El caché expiró completamente, la siguiente solicitud va al origen
Tus usuarios casi siempre reciben una respuesta cacheada. El tradeoff de frescura es explícito y configurable.
Edge Config para configuración dinámica#
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.
Las matemáticas reales de rendimiento#
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:
Escenario: API de perfil de usuario (2 consultas a la 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:
- Edge + base de datos en el origen = frecuentemente más lento que simplemente usar un servidor regional
- Edge + base de datos multi-región = más rápido pero más caro y complejo
- Edge para gatekeeping + regional para datos = mejor balance pragmático
- Edge puro (sin base de datos) = imbatible para cosas como redirecciones y verificaciones de auth
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).
Cuando el Edge realmente gana: Operaciones sin base de datos#
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.
Mi framework de decisión#
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:
Paso 1: ¿Necesita APIs de Node.js?#
Si importa fs, net, child_process, o cualquier módulo nativo — Node.js regional. Sin debate.
Paso 2: ¿Necesita consultas a la base de datos?#
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.
Paso 3: ¿Es una decisión sobre una solicitud (routing, auth, redirección)?#
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.
Paso 4: ¿La respuesta es cacheable?#
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.
Paso 5: ¿Es intensivo en CPU?#
Si involucra cómputo significativo (procesamiento de imágenes, generación de PDFs, transformaciones grandes de datos) — Node.js regional.
Paso 6: ¿Qué tan sensible a la latencia es?#
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.
La cheat sheet#
// ✅ 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)Lo que yo realmente corro en el Edge#
Para este sitio, así se desglosa:
Edge (middleware):
- Detección de locale y redirección
- Filtrado de bots
- Headers de seguridad (CSP, HSTS, etc.)
- Logging de acceso
- Rate limiting (básico)
Node.js regional:
- Renderizado de contenido del blog (el procesamiento MDX necesita APIs de Node.js a través de Velite)
- Rutas de API que tocan Redis
- Generación de imágenes OG (necesita más tiempo de CPU)
- Generación de feeds RSS
Estático (sin runtime en absoluto):
- Páginas de herramientas (pre-renderizadas en build time)
- Páginas de posts del blog (pre-renderizadas en build time)
- Todas las imágenes y assets (servidos por CDN)
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.
El resumen honesto#
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.