Edge Function: Cosa Sono, Quando Usarle e Quando Evitarle
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.
Cos'è l'Edge?#
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.
V8 Isolate vs Node.js#
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:
- Memoria: 128MB per isolate (Cloudflare Workers), 256MB su alcune piattaforme
- Tempo CPU: 10-50ms di tempo CPU effettivo (non wall clock time —
await fetch()non conta, maJSON.parse()su un payload da 5MB sì) - Nessun modulo nativo: Qualsiasi cosa che richieda un binding C++ (bcrypt, sharp, canvas) è fuori discussione
- Nessuna connessione persistente: Non puoi mantenere aperta una connessione al database tra le richieste
- Limiti di dimensione del bundle: Tipicamente 1-5MB per l'intero script del worker
Questo non è Node.js su un CDN. È un runtime diverso con un modello mentale diverso.
Cold Start: Mito e Realtà#
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ì:
- Arriva la richiesta
- La piattaforma avvia un container (se nessuno disponibile)
- Il container avvia il sistema operativo
- Il runtime si inizializza (Node.js, Python, ecc.)
- Il tuo codice si carica e si inizializza
- La richiesta viene elaborata
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:
- Arriva la richiesta
- La piattaforma crea un nuovo V8 isolate (o ne riusa uno warm)
- Il tuo codice si carica (è già compilato in bytecode al momento del deploy)
- La richiesta viene elaborata
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)];Casi d'Uso Perfetti per le Edge Function#
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.
1. Redirect Basati sulla Geolocalizzazione#
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.
2. A/B Testing Senza Flicker Lato Client#
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.
3. Verifica dei Token di Autenticazione#
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").
4. Rilevamento Bot e Rate Limiting#
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.
5. Riscrittura delle Richieste e Header di Personalizzazione#
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;
}Dove l'Edge Fallisce#
Questa è la sezione che le pagine marketing saltano. Ho sbattuto contro ognuno di questi muri.
1. Connessioni al Database#
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);
}2. Task di Lunga Durata#
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.
3. Dipendenze Solo Node.js#
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.
4. Bundle di Grandi Dimensioni#
Le piattaforme edge hanno limiti rigorosi sulle dimensioni del bundle:
- Cloudflare Workers: 1MB (gratuito), 5MB (a pagamento)
- Vercel Edge Functions: 4MB (compresso)
- Deno Deploy: 20MB
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";5. Streaming e WebSocket#
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 Edge Runtime#
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.
Middleware (Sempre 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).*)",
],
};API Route all'Edge#
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(),
});
}Route di Pagina all'Edge#
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>
);
}Cosa È Disponibile nell'Edge Runtime#
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 webAutenticazione all'Edge: Il Pattern Completo#
Voglio 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.
Caching all'Edge#
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.
Cache-Control Fatto Bene#
// 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:
- Prima richiesta: L'edge recupera dall'origine, mette in cache la risposta, la restituisce
- Richieste entro 60 secondi: L'edge serve dalla cache (0ms di latenza all'origine)
- Richiesta a 61-360 secondi: L'edge serve la versione stale in cache immediatamente, ma recupera una versione fresca dall'origine in background
- Dopo 360 secondi: La cache è completamente scaduta, la prossima richiesta va all'origine
I tuoi utenti ricevono quasi sempre una risposta cachata. Il compromesso sulla freschezza è esplicito e regolabile.
Edge Config per la Configurazione Dinamica#
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.
La Vera Matematica delle Performance#
Facciamo la matematica onesta invece di quella del marketing. Confronterò tre architetture per un tipico endpoint API che deve interrogare un database:
Scenario: API Profilo Utente (2 query al 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:
- Edge + database all'origine = spesso più lento che usare semplicemente un server regionale
- Edge + database multi-regione = più veloce ma più costoso e complesso
- Edge per il gatekeeping + regionale per i dati = miglior equilibrio pragmatico
- Edge puro (senza database) = imbattibile per cose come redirect e controlli auth
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).
Quando l'Edge Vince Davvero: Operazioni Senza Database#
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.
Il Mio Framework Decisionale#
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:
Passo 1: Ha bisogno delle API Node.js?#
Se importa fs, net, child_process, o qualsiasi modulo nativo — Node.js regionale. Nessun dibattito.
Passo 2: Ha bisogno di query al database?#
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.
Passo 3: È una decisione su una richiesta (routing, auth, redirect)?#
Se sì — Edge. Questo è il punto forte. Stai prendendo una decisione veloce che determina cosa succede alla richiesta prima che raggiunga l'origine.
Passo 4: La risposta è cacheable?#
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.
Passo 5: È CPU-intensive?#
Se comporta calcoli significativi (elaborazione immagini, generazione PDF, trasformazioni di grandi dati) — Node.js regionale.
Passo 6: Quanto è sensibile alla latenza?#
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.
Il Cheat Sheet#
// ✅ 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)Cosa Eseguo Effettivamente all'Edge#
Per questo sito, ecco la suddivisione:
Edge (middleware):
- Rilevamento e redirect della locale
- Filtraggio bot
- Header di sicurezza (CSP, HSTS, ecc.)
- Logging degli accessi
- Rate limiting (base)
Node.js regionale:
- Rendering dei contenuti del blog (l'elaborazione MDX necessita delle API Node.js tramite Velite)
- Route API che toccano Redis
- Generazione immagini OG (necessita più tempo CPU)
- Generazione feed RSS
Statico (nessun runtime):
- Pagine degli strumenti (pre-renderizzate al build time)
- Pagine dei post del blog (pre-renderizzate al build time)
- Tutte le immagini e gli asset (serviti dal CDN)
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.
Il Riassunto Onesto#
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.