Edge runtime, V8 isolate, il mito del cold start, geo-routing, A/B testing, autenticazione all'edge e perché ho spostato alcune cose di nuovo su Node.js. Uno sguardo equilibrato sull'edge computing.
La parola "edge" viene usata tantissimo. Lo dice Vercel. Lo dice Cloudflare. Lo dice Deno. Ogni talk a ogni conferenza sulle performance web menziona inevitabilmente "eseguire all'edge" come se fosse una formula magica che rende la tua app veloce.
Ci ho creduto anch'io. Ho spostato middleware, API route e persino della logica di rendering sull'edge runtime. Alcune di queste scelte sono state brillanti. Altre le ho silenziosamente riportate su Node.js tre settimane dopo, mentre debuggavo errori del connection pool alle 2 di notte.
Questo post è la versione equilibrata di quella storia — cos'è davvero l'edge, dove brilla genuinamente, dove assolutamente no, e come decido quale runtime usare per ogni pezzo della mia applicazione.
Partiamo dalla geografia. Quando qualcuno visita il tuo sito web, la sua richiesta viaggia dal suo dispositivo, attraverso il suo ISP, attraverso internet fino al tuo server, viene elaborata, e la risposta fa tutto il cammino inverso. Se il tuo server è in us-east-1 (Virginia) e il tuo utente è a Tokyo, quel viaggio di andata e ritorno copre circa 14.000 km. Alla velocità della luce attraverso la fibra, sono circa 70ms solo per la fisica — solo andata. Aggiungi la risoluzione DNS, l'handshake TLS e qualsiasi tempo di elaborazione, e arrivi facilmente a 200-400ms prima che il tuo utente veda un singolo byte.
L'"edge" significa eseguire il tuo codice su server distribuiti globalmente — gli stessi nodi CDN che hanno sempre servito asset statici, ma ora possono anche eseguire la tua logica. Invece di un unico server di origine in Virginia, il tuo codice gira in più di 300 località nel mondo. Un utente a Tokyo raggiunge un server a Tokyo. Un utente a Parigi raggiunge un server a Parigi.
La matematica della latenza è semplice e convincente:
Tradizionale (origine singola):
Tokyo → Virginia: ~140ms round trip (solo fisica)
+ TLS handshake: ~140ms in più (un altro round trip)
+ Elaborazione: 20-50ms
Totale: ~300-330ms
Edge (PoP locale):
Tokyo → nodo edge Tokyo: ~5ms round trip
+ TLS handshake: ~5ms in più
+ Elaborazione: 5-20ms
Totale: ~15-30ms
È un miglioramento di 10-20x per la risposta iniziale. È reale, è misurabile, e per certe operazioni è trasformativo.
Ma ecco cosa il marketing sorvola: l'edge non è un ambiente server completo. È qualcosa di fondamentalmente diverso.
Node.js tradizionale gira in un processo del sistema operativo completo. Ha accesso al filesystem, può aprire connessioni TCP, può avviare processi figli, può leggere variabili d'ambiente come stream, può fare essenzialmente qualsiasi cosa che un processo Linux può fare.
Le edge function non girano su Node.js. Girano su V8 isolate — lo stesso motore JavaScript che alimenta Chrome, ma ridotto al suo nucleo. Pensa a un V8 isolate come a una sandbox leggera:
// Questo funziona in Node.js ma NON all'edge
import fs from "fs";
import { createConnection } from "net";
import { execSync } from "child_process";
const file = fs.readFileSync("/etc/hosts"); // ❌ Nessun filesystem
const conn = createConnection({ port: 5432 }); // ❌ Nessun TCP grezzo
const result = execSync("ls -la"); // ❌ Nessun processo figlio
process.env.DATABASE_URL; // ⚠️ Disponibile ma statico, impostato al deployQuello che HAI all'edge è la superficie delle Web API — le stesse API disponibili in un browser:
// Queste funzionano tutte all'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());I vincoli sono reali e stringenti:
await fetch() non conta, ma JSON.parse() su un payload da 5MB sì)Questo non è Node.js su un CDN. È un runtime diverso con un modello mentale diverso.
Probabilmente hai sentito che le edge function hanno "zero cold start". Questo è... per lo più vero, e il confronto è genuinamente drammatico.
Una funzione serverless tradizionale basata su container (AWS Lambda, Google Cloud Functions) funziona così:
I passi 2-5 sono il cold start. Per una Lambda Node.js, si tratta tipicamente di 200-500ms. Per una Lambda Java, può essere 2-5 secondi. Per una Lambda .NET, 500ms-1.5s.
I V8 isolate funzionano diversamente:
I passi 2-3 richiedono meno di 5ms. Spesso meno di 1ms. L'isolate non è un container — non c'è un sistema operativo da avviare, nessun runtime da inizializzare. V8 crea un isolate fresco in microsecondi. La frase "zero cold start" è linguaggio marketing, ma la realtà (startup sotto i 5ms) è abbastanza vicina a zero da non importare per la maggior parte dei casi d'uso.
Ma ecco quando i cold start ti mordono ancora all'edge:
Bundle grandi. Se la tua edge function include 2MB di dipendenze, quel codice deve comunque essere caricato e parsato. L'ho imparato a mie spese quando ho incluso una libreria di validazione e una libreria di formattazione date in un edge middleware. Il cold start è passato da 2ms a 40ms. Ancora veloce, ma non "zero".
Località rare. I provider edge hanno centinaia di PoP, ma non tutti i PoP mantengono il tuo codice warm. Se ricevi una richiesta all'ora da Nairobi, quell'isolate viene riciclato tra le richieste. La richiesta successiva paga di nuovo il costo di startup.
Più isolate per richiesta. Se la tua edge function chiama un'altra edge function (o se sia il middleware che un API route sono edge), potresti star avviando più isolate per una singola richiesta utente.
Il consiglio pratico: mantieni i bundle delle tue edge function piccoli. Importa solo ciò che ti serve. Fai tree-shaking aggressivo. Più piccolo è il bundle, più veloce è il cold start, più la promessa del "zero cold start" regge.
// ❌ Non fare questo all'edge
import dayjs from "dayjs";
import * as yup from "yup";
import lodash from "lodash";
// ✅ Fai questo invece — usa le API built-in
const date = new Date().toISOString();
const isValid = typeof input === "string" && input.length < 200;
const unique = [...new Set(items)];Dopo aver sperimentato ampiamente, ho trovato un pattern chiaro: le edge function eccellono quando devi prendere una decisione rapida su una richiesta prima che raggiunga il tuo server di origine. Sono gatekeeper, router e trasformatori — non application server.
Questo è il caso d'uso killer. La richiesta colpisce il nodo edge più vicino, che sa già dove si trova l'utente. Nessuna chiamata API necessaria, nessun database di IP lookup — la piattaforma fornisce i dati geo:
// middleware.ts — gira all'edge su ogni richiesta
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 allo store specifico per paese
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));
}
}
// Aggiungi header geo per uso a valle
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;
}Questo gira in meno di 5ms, proprio accanto all'utente. L'alternativa — mandare la richiesta fino al tuo server di origine solo per fare un IP lookup e reindirizzare — costerebbe 100-300ms per gli utenti lontani dalla tua origine.
L'A/B testing lato client causa il temuto "flash of original content" — l'utente vede la versione A per una frazione di secondo prima che JavaScript sostituisca la versione B. All'edge, puoi assegnare la variante prima che la pagina inizi a renderizzarsi:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Controlla se l'utente ha già un'assegnazione di variante
const existingVariant = request.cookies.get("ab-variant")?.value;
if (existingVariant) {
// Riscrivi alla pagina della variante corretta
const url = request.nextUrl.clone();
url.pathname = `/variants/${existingVariant}${url.pathname}`;
return NextResponse.rewrite(url);
}
// Assegna una nuova variante (split 50/50)
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 giorni
httpOnly: true,
sameSite: "lax",
});
return response;
}L'utente non vede mai un flicker perché la riscrittura avviene a livello di rete. Il browser non sa nemmeno che era un A/B test — riceve semplicemente la pagina della variante direttamente.
Se la tua autenticazione usa JWT (e non fai lookup di sessione nel database), l'edge è perfetto. La verifica JWT è pura crittografia — nessun database necessario:
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",
});
// Passa le info utente a valle come header
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 scaduto o non valido
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("session-token");
return response;
}
}Questo pattern è potente: l'edge middleware verifica il token e passa le informazioni utente alla tua origine come header fidati. Le tue API route non devono verificare il token di nuovo — leggono semplicemente request.headers.get("x-user-id").
Le edge function possono bloccare il traffico indesiderato prima che raggiunga la tua origine:
import { NextRequest, NextResponse } from "next/server";
// Semplice rate limiter in memoria (per località edge)
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") ?? "";
// Blocca bot noti malevoli
const badBots = ["AhrefsBot", "SemrushBot", "MJ12bot", "DotBot"];
if (badBots.some((bot) => ua.includes(bot))) {
return new NextResponse("Forbidden", { status: 403 });
}
// Rate limiting semplice
const now = Date.now();
const windowMs = 60_000; // 1 minuto
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 });
}
// Pulizia periodica per prevenire 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 precisazione: la mappa del rate limit sopra è per-isolate, per-località. Se hai 300 località edge, ognuna ha la propria mappa. Per un rate limiting rigoroso, ti serve uno store distribuito come Upstash Redis o Cloudflare Durable Objects. Ma per una prevenzione approssimativa degli abusi, i limiti per-località funzionano sorprendentemente bene.
Le edge function sono eccellenti nel trasformare le richieste prima che raggiungano la tua origine:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const url = request.nextUrl;
// Negoziazione dei contenuti basata sul dispositivo
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 flag dal cookie
const flags = request.cookies.get("feature-flags")?.value;
if (flags) {
response.headers.set("x-feature-flags", flags);
}
// Rilevamento locale per 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;
}Questa è la sezione che le pagine marketing saltano. Ho sbattuto contro ognuno di questi muri.
Questo è il problema principale. I database tradizionali (PostgreSQL, MySQL) usano connessioni TCP persistenti. Un server Node.js apre un connection pool all'avvio e riutilizza quelle connessioni tra le richieste. Efficiente, collaudato, ben compreso.
Le edge function non possono farlo. Ogni isolate è effimero. Non c'è una fase di "avvio" dove apri le connessioni. Anche se potessi aprire una connessione, l'isolate potrebbe essere riciclato dopo una richiesta, sprecando il tempo di setup della connessione.
// ❌ Questo pattern fondamentalmente non funziona all'edge
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // Connection pool di 10
});
// Ogni invocazione edge:
// 1. Creerebbe un nuovo Pool (non riutilizzabile tra invocazioni in modo affidabile)
// 2. Aprirebbe una connessione TCP al tuo database (che è in us-east-1, non all'edge)
// 3. Farebbe l'handshake TLS con il database
// 4. Eseguirebbe la query
// 5. Scarterebbe la connessione quando l'isolate viene riciclato
// Anche con servizi di connection pooling come PgBouncer,
// stai comunque pagando la latenza di rete dall'edge → database di origineIl problema del round-trip al database è fondamentale. Il tuo database è in una regione. La tua edge function è in 300 regioni. Ogni query al database dall'edge deve viaggiare dalla località edge alla regione del database e ritorno. Per un utente a Tokyo che raggiunge un nodo edge a Tokyo, ma il tuo database è in Virginia:
Edge function a Tokyo
→ Query a PostgreSQL in Virginia: ~140ms round trip
→ Seconda query: ~140ms in più
→ Totale: 280ms solo per due query
Funzione Node.js in Virginia (stessa regione del DB)
→ Query a PostgreSQL: ~1ms round trip
→ Seconda query: ~1ms in più
→ Totale: 2ms per due query
La edge function è 140 volte più lenta per le operazioni database in questo scenario. Non importa che la edge function sia partita più velocemente — i round trip al database dominano tutto.
Ecco perché esistono i proxy database basati su HTTP (il driver serverless di Neon, il driver basato su fetch di PlanetScale, l'API REST di Supabase). Funzionano, ma stanno comunque facendo richieste HTTP a un database in una singola regione. Risolvono il problema del "non posso usare TCP" ma non quello del "il database è lontano".
// ✅ Questo funziona all'edge (accesso al database basato su HTTP)
// Ma è comunque lento se il database è lontano dal nodo edge
import { neon } from "@neondatabase/serverless";
export const runtime = "edge";
export async function GET(request: Request) {
const sql = neon(process.env.DATABASE_URL!);
// Questa fa una richiesta HTTP al tuo database Neon
// Funziona, ma la latenza dipende dalla distanza alla regione del database
const posts = await sql`SELECT * FROM posts WHERE published = true LIMIT 10`;
return Response.json(posts);
}Le edge function hanno limiti di tempo CPU, tipicamente 10-50ms di tempo di calcolo effettivo. Il wall clock time è più generoso (di solito 30 secondi), ma le operazioni intensive sulla CPU raggiungeranno il limite velocemente:
// ❌ Questi supereranno i limiti di tempo CPU all'edge
export const runtime = "edge";
export async function POST(request: Request) {
const data = await request.json();
// Elaborazione immagini — CPU intensive
// (Inoltre non puoi usare sharp perché è un modulo nativo)
const processed = heavyImageProcessing(data.image);
// Generazione PDF — CPU intensive + necessita API Node.js
const pdf = generatePDF(data.content);
// Trasformazione di grandi dati
const result = data.items // 100.000 elementi
.map(transform)
.filter(validate)
.sort(compare)
.reduce(aggregate, {});
return Response.json(result);
}Se la tua funzione ha bisogno di più di pochi millisecondi di tempo CPU, appartiene a un server Node.js regionale. Punto.
Questa coglie la gente di sorpresa. Un numero sorprendente di pacchetti npm dipende dai moduli built-in di Node.js:
// ❌ Questi pacchetti non funzioneranno all'edge
import bcrypt from "bcrypt"; // Binding C++ nativo
import sharp from "sharp"; // Binding C++ nativo
import puppeteer from "puppeteer"; // Necessita filesystem + child_process
import nodemailer from "nodemailer"; // Necessita modulo net
import { readFile } from "fs/promises"; // API filesystem Node.js
import mongoose from "mongoose"; // Connessioni TCP + API Node.js
// ✅ Alternative compatibili con l'edge
import { hashSync } from "bcryptjs"; // Implementazione JS pura (più lenta)
// Per le immagini: usa un servizio o API separato
// Per le email: usa un'API email basata su HTTP (Resend, SendGrid REST)
// Per il database: usa client basati su HTTPPrima di spostare qualsiasi cosa all'edge, controlla ogni dipendenza. Un singolo require("fs") sepolto tre livelli in profondità nel tuo albero delle dipendenze farà crashare la tua edge function a runtime — non al build time. Farai il deploy, tutto sembra a posto, poi la prima richiesta raggiunge quel percorso di codice e ottieni un errore criptico.
Le piattaforme edge hanno limiti rigorosi sulle dimensioni del bundle:
Sembra abbastanza fino a quando non fai import di una libreria di componenti UI, una libreria di validazione e una libreria per le date. Una volta ho avuto un edge middleware che è cresciuto fino a 3.5MB perché ho importato da un barrel file che trascinava dentro l'intera directory @/components.
// ❌ I barrel file possono trascinare troppa roba
import { validateEmail } from "@/lib/utils";
// Se utils.ts ri-esporta da 20 altri moduli, tutti vengono inclusi nel bundle
// ✅ Importa direttamente dalla fonte
import { validateEmail } from "@/lib/validators/email";Le edge function possono fare risposte in streaming (Web Streams API), ma le connessioni WebSocket di lunga durata sono un'altra storia. Sebbene alcune piattaforme supportino i WebSocket all'edge (Cloudflare Workers, Deno Deploy), la natura effimera delle edge function le rende poco adatte per connessioni stateful di lunga durata.
Next.js rende semplice optare per l'edge runtime su base per-route. Non devi andare all-in — scegli esattamente quali route girano all'edge.
Il middleware di Next.js gira sempre all'edge. Questo è by design — il middleware intercetta ogni richiesta corrispondente, quindi deve essere veloce e distribuito globalmente:
// middleware.ts — gira sempre all'edge, non serve opt-in
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Questo gira prima di ogni richiesta corrispondente
// Mantienilo veloce — niente chiamate al database, niente calcoli pesanti
return NextResponse.next();
}
export const config = {
// Esegui solo su percorsi specifici
matcher: [
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
],
};Qualsiasi route handler può optare per l'edge runtime:
// app/api/hello/route.ts
export const runtime = "edge"; // Questa singola riga cambia il runtime
export async function GET(request: Request) {
return Response.json({
message: "Hello from the edge",
region: process.env.VERCEL_REGION ?? "unknown",
timestamp: Date.now(),
});
}Anche intere pagine possono renderizzare all'edge, anche se ci penserei bene prima di farlo:
// app/dashboard/page.tsx
export const runtime = "edge";
export default async function DashboardPage() {
// Ricorda: niente API Node.js qui
// Qualsiasi data fetching deve usare fetch() o client compatibili con l'edge
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>
{/* renderizza i dati */}
</main>
);
}Ecco un riferimento pratico di cosa puoi e non puoi usare:
// ✅ Disponibile all'edge
fetch() // Richieste HTTP
Request / Response // Request/response standard web
Headers // Header HTTP
URL / URLSearchParams // Parsing URL
TextEncoder / TextDecoder // Encoding stringhe
crypto.subtle // Operazioni crittografiche (firma, hashing)
crypto.randomUUID() // Generazione UUID
crypto.getRandomValues() // Numeri random crittografici
structuredClone() // Clonazione profonda
atob() / btoa() // Encoding/decoding Base64
setTimeout() / setInterval() // Timer (ma ricorda i limiti CPU)
console.log() // Logging
ReadableStream / WritableStream // Streaming
AbortController / AbortSignal // Cancellazione richieste
URLPattern // Pattern matching URL
// ❌ NON disponibile all'edge
require() // CommonJS (usa import)
fs / path / os // Moduli built-in Node.js
process.exit() // Controllo del processo
Buffer // Usa Uint8Array al suo posto
__dirname / __filename // Usa import.meta.url
setImmediate() // Non è uno standard webVoglio approfondire l'autenticazione perché è uno dei casi d'uso più impattanti dell'edge, ma è anche facile sbagliare.
Il pattern che funziona è: verifica il token all'edge, passa i claim fidati a valle, non toccare mai il database nel middleware.
// lib/edge-auth.ts — Utility auth compatibili con l'edge
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 secondi di tolleranza clock skew
});
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 — Il middleware di autenticazione
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;
// Salta l'auth per i percorsi pubblici
if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith("/api/public"))) {
return NextResponse.next();
}
// Estrai il token
const token = request.cookies.get("auth-token")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Verifica il token (pura crittografia — nessuna chiamata al database)
const payload = await verifyToken(token);
if (!payload) {
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("auth-token");
return response;
}
// Controllo accesso basato sui ruoli
if (ADMIN_PATHS.some((p) => pathname.startsWith(p)) && payload.role !== "admin") {
return NextResponse.redirect(new URL("/unauthorized", request.url));
}
// Passa le info utente verificate all'origine come header fidati
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);
// Segnala se il token ha bisogno di refresh
if (isTokenExpiringSoon(payload)) {
response.headers.set("x-token-refresh", "true");
}
return response;
}// app/api/profile/route.ts — Il server di origine legge gli header fidati
export async function GET(request: Request) {
// Questi header sono stati impostati dall'edge middleware dopo la verifica JWT
// Sono fidati perché provengono dalla nostra stessa infrastruttura
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 });
}
// Ora possiamo interrogare il database — siamo sul server di origine,
// proprio accanto al database, con un connection pool
const user = await db.user.findUnique({ where: { id: userId } });
return Response.json(user);
}L'intuizione chiave: l'edge fa la parte veloce (verifica crittografica), e l'origine fa la parte lenta (query al database). Ognuno gira dove è più efficiente.
Una precisazione importante: questo funziona solo per i JWT. Se il tuo sistema di autenticazione richiede un lookup nel database ad ogni richiesta (come l'autenticazione basata su sessione con un cookie di session ID), l'edge non può aiutarti — dovresti comunque chiamare il database, il che significa un round trip alla regione di origine.
Il caching all'edge è dove le cose si fanno interessanti. I nodi edge possono mettere in cache le risposte, il che significa che le richieste successive allo stesso URL vengono servite direttamente dall'edge senza raggiungere la tua origine.
// 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 sul CDN per 60 secondi
// Servi stale mentre rivalidhi per fino a 5 minuti
// Il client può cacheare per 10 secondi
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300, max-age=10",
// Varia in base a questi header così varianti diverse ottengono entry di cache diverse
Vary: "Accept-Language, Accept-Encoding",
// Tag cache specifico per CDN per invalidazione mirata
"Cache-Tag": `products,category-${category}`,
},
});
}Il pattern stale-while-revalidate è particolarmente potente all'edge. Ecco cosa succede:
I tuoi utenti ricevono quasi sempre una risposta cachata. Il compromesso sulla freschezza è esplicito e regolabile.
Edge Config di Vercel (e servizi simili di altre piattaforme) ti permette di memorizzare configurazione chiave-valore che viene replicata su ogni località edge. Questo è incredibilmente utile per feature flag, regole di redirect e configurazione di A/B test che vuoi aggiornare senza fare un nuovo deploy:
import { get } from "@vercel/edge-config";
import { NextRequest, NextResponse } from "next/server";
export async function middleware(request: NextRequest) {
// Le letture da Edge Config sono estremamente veloci (~1ms) perché
// i dati sono replicati su ogni località edge
const maintenanceMode = await get<boolean>("maintenance_mode");
if (maintenanceMode) {
return NextResponse.rewrite(new URL("/maintenance", request.url));
}
// Feature flag
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));
}
// Redirect dinamici (aggiorna i redirect senza rideploy)
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();
}Questo è un vero game-changer. Prima di Edge Config, cambiare un feature flag significava una modifica al codice e un rideploy. Ora aggiorni un valore JSON in una dashboard e si propaga globalmente in pochi secondi.
Facciamo la matematica onesta invece di quella del marketing. Confronterò tre architetture per un tipico endpoint API che deve interrogare un database:
Architettura A: Node.js Regionale Tradizionale
Utente a Tokyo → Origine in Virginia: 140ms
+ Query DB 1 (stessa regione): 2ms
+ Query DB 2 (stessa regione): 2ms
+ Elaborazione: 5ms
= Totale: ~149ms
Architettura B: Edge Function con Database HTTP
Utente a Tokyo → Edge a Tokyo: 5ms
+ Query DB 1 (HTTP verso Virginia): 145ms
+ Query DB 2 (HTTP verso Virginia): 145ms
+ Elaborazione: 3ms
= Totale: ~298ms ← PIÙ LENTO del regionale
Architettura C: Edge Function con Database Regionale (read replica)
Utente a Tokyo → Edge a Tokyo: 5ms
+ Query DB 1 (HTTP verso replica Tokyo): 8ms
+ Query DB 2 (HTTP verso replica Tokyo): 8ms
+ Elaborazione: 3ms
= Totale: ~24ms ← Più veloce, ma richiede DB multi-regione
Architettura D: Edge per Auth + Regionale per i Dati
Utente a Tokyo → Edge middleware a Tokyo: 5ms (verifica JWT)
→ Origine in Virginia: 140ms
+ Query DB 1 (stessa regione): 2ms
+ Query DB 2 (stessa regione): 2ms
+ Elaborazione: 5ms
= Totale: ~154ms
(Ma l'auth è già verificata — l'origine non deve ri-verificare)
(E le richieste non autorizzate sono bloccate all'edge — non raggiungono mai l'origine)
Le conclusioni:
L'Architettura D è quella che uso per la maggior parte dei progetti. L'edge gestisce ciò in cui è bravo (decisioni rapide, auth, routing), e il server Node.js regionale gestisce ciò in cui è bravo (query al database, calcoli pesanti).
La matematica si ribalta completamente quando non c'è un database coinvolto:
Redirect (edge):
Utente a Tokyo → Edge a Tokyo → risposta redirect: ~5ms
Redirect (regionale):
Utente a Tokyo → Origine in Virginia → risposta redirect: ~280ms
Risposta API statica (edge + cache):
Utente a Tokyo → Edge a Tokyo → risposta cachata: ~5ms
Risposta API statica (regionale):
Utente a Tokyo → Origine in Virginia → risposta: ~280ms
Blocco bot (edge):
Bot malevolo ovunque → Edge (più vicino) → risposta 403: ~5ms
(Il bot non raggiunge mai il tuo server di origine)
Blocco bot (regionale):
Bot malevolo ovunque → Origine in Virginia → risposta 403: ~280ms
(Il bot ha comunque consumato risorse dell'origine)
Per le operazioni che non necessitano di un database, l'edge è 20-50 volte più veloce. Questo non è marketing — è fisica.
Dopo un anno di lavoro con le edge function in produzione, ecco il diagramma di flusso che uso per ogni nuovo endpoint o pezzo di logica:
Se importa fs, net, child_process, o qualsiasi modulo nativo — Node.js regionale. Nessun dibattito.
Se sì, e non hai read replica vicine ai tuoi utenti — Node.js regionale (nella stessa regione del tuo database). I round trip al database domineranno.
Se sì, e hai read replica distribuite globalmente — Edge può funzionare, usando client database basati su HTTP.
Se sì — Edge. Questo è il punto forte. Stai prendendo una decisione veloce che determina cosa succede alla richiesta prima che raggiunga l'origine.
Se sì — Edge con header Cache-Control appropriati. Anche se la prima richiesta va alla tua origine, le richieste successive vengono servite dalla cache edge.
Se comporta calcoli significativi (elaborazione immagini, generazione PDF, trasformazioni di grandi dati) — Node.js regionale.
Se è un job in background o un webhook — Node.js regionale. Nessuno lo sta aspettando. Se è una richiesta rivolta all'utente dove ogni ms conta — Edge, se soddisfa gli altri criteri.
// ✅ PERFETTO per l'edge
// - Middleware (auth, redirect, riscritture, header)
// - Logica di geolocalizzazione
// - Assegnazione A/B test
// - Rilevamento bot / regole WAF
// - Risposte API cache-friendly
// - Controlli feature flag
// - Risposte preflight CORS
// - Trasformazioni di dati statici (senza DB)
// - Verifica firma webhook
// ❌ TIENI su Node.js regionale
// - Operazioni CRUD database
// - Upload / elaborazione file
// - Manipolazione immagini
// - Generazione PDF
// - Invio email (usa API HTTP, ma comunque regionale)
// - Server WebSocket
// - Job in background / code
// - Qualsiasi cosa che usi pacchetti npm nativi
// - Pagine SSR con query al database
// - Resolver GraphQL che interrogano database
// 🤔 DIPENDE
// - Autenticazione (edge per JWT, regionale per sessione-DB)
// - API route (edge se senza DB, regionale se con DB)
// - Pagine server-rendered (edge se i dati vengono da cache/fetch, regionale se DB)
// - Funzionalità real-time (edge per auth iniziale, regionale per connessioni persistenti)Per questo sito, ecco la suddivisione:
Edge (middleware):
Node.js regionale:
Statico (nessun runtime):
Il miglior runtime è spesso nessun runtime. Se puoi pre-renderizzare qualcosa al build time e servirlo come asset statico, sarà sempre più veloce di qualsiasi edge function. L'edge è per le cose che genuinamente devono essere dinamiche ad ogni richiesta.
Le edge function non sono un sostituto per i server tradizionali. Sono un complemento. Sono uno strumento aggiuntivo nella tua cassetta degli attrezzi architetturale — incredibilmente potente per i casi d'uso giusti e attivamente dannoso per quelli sbagliati.
L'euristica a cui torno sempre: se la tua funzione deve raggiungere un database in una singola regione, mettere la funzione all'edge non aiuta — peggiora le cose. Hai appena aggiunto un hop. La funzione gira più velocemente, ma poi spende 100ms+ per raggiungere il database. Risultato netto: più lento che eseguire tutto in una regione.
Ma per le decisioni che possono essere prese solo con le informazioni nella richiesta stessa — geolocalizzazione, cookie, header, JWT — l'edge è imbattibile. Quelle risposte da 5ms all'edge non sono benchmark sintetici. Sono reali, e i tuoi utenti sentono la differenza.
Non spostare tutto all'edge. Non tenere tutto lontano dall'edge. Metti ogni pezzo di logica dove la fisica lo favorisce.