Edge Functions: Wat Ze Zijn, Wanneer Je Ze Gebruikt, en Wanneer Niet
Edge runtime, V8 isolates, de cold start-mythe, geo-routing, A/B-testen, auth aan de edge, en waarom ik sommige dingen terugverhuisde naar Node.js. Een gebalanceerde kijk op edge computing.
Het woord "edge" wordt te pas en te onpas gebruikt. Vercel zegt het. Cloudflare zegt het. Deno zegt het. Elke conferentiepresentatie over webperformance noemt onvermijdelijk "draaien aan de edge" alsof het een toverformule is die je app snel maakt.
Ik trapte erin. Ik verplaatste middleware, API-routes, zelfs wat renderlogica naar de edge runtime. Sommige van die beslissingen waren briljant. Andere verplaatste ik stilletjes drie weken later terug naar Node.js, nadat ik om 2 uur 's nachts connection pool-fouten had zitten debuggen.
Dit artikel is de gebalanceerde versie van dat verhaal — wat de edge werkelijk is, waar het echt uitblinkt, waar het absoluut niet werkt, en hoe ik besluit welke runtime ik gebruik voor elk onderdeel van mijn applicatie.
Wat Is de Edge?#
Laten we beginnen met geografie. Wanneer iemand je website bezoekt, reist hun verzoek van hun apparaat, via hun internetprovider, over het internet naar je server, wordt verwerkt, en het antwoord reist helemaal terug. Als je server in us-east-1 (Virginia) staat en je gebruiker in Tokio zit, bestrijkt die roundtrip ongeveer 14.000 km. Met de snelheid van het licht door glasvezel is dat ongeveer 70ms alleen al voor de fysica — één kant op. Tel daar DNS-resolutie, TLS-handshake en eventuele verwerkingstijd bij op, en je kijkt al snel naar 200-400ms voordat je gebruiker ook maar één byte ziet.
De "edge" betekent dat je code draait op servers die wereldwijd verspreid zijn — dezelfde CDN-nodes die altijd al statische assets hebben geserveerd, maar die nu ook je logica kunnen uitvoeren. In plaats van één origin-server in Virginia draait je code op meer dan 300 locaties wereldwijd. Een gebruiker in Tokio bereikt een server in Tokio. Een gebruiker in Parijs bereikt een server in Parijs.
De latency-berekening is simpel en overtuigend:
Traditioneel (enkele origin):
Tokio → Virginia: ~140ms roundtrip (alleen fysica)
+ TLS-handshake: ~140ms extra (nog een roundtrip)
+ Verwerking: 20-50ms
Totaal: ~300-330ms
Edge (lokale PoP):
Tokio → Tokio edge node: ~5ms roundtrip
+ TLS-handshake: ~5ms extra
+ Verwerking: 5-20ms
Totaal: ~15-30ms
Dat is een 10-20x verbetering voor de initiële response. Het is echt, het is meetbaar, en voor bepaalde operaties is het transformatief.
Maar hier is wat de marketing verdoezelt: de edge is geen volledige serveromgeving. Het is iets fundamenteel anders.
V8 Isolates vs Node.js#
Traditioneel Node.js draait in een volledig besturingssysteemproces. Het heeft toegang tot het bestandssysteem, het kan TCP-verbindingen openen, het kan child processes starten, het kan omgevingsvariabelen als een stream lezen, het kan in wezen alles doen wat een Linux-proces kan doen.
Edge functions draaien niet op Node.js. Ze draaien op V8 isolates — dezelfde JavaScript-engine die Chrome aandrijft, maar ontdaan tot de kern. Beschouw een V8 isolate als een lichtgewicht sandbox:
// Dit werkt in Node.js maar NIET aan de edge
import fs from "fs";
import { createConnection } from "net";
import { execSync } from "child_process";
const file = fs.readFileSync("/etc/hosts"); // ❌ Geen bestandssysteem
const conn = createConnection({ port: 5432 }); // ❌ Geen raw TCP
const result = execSync("ls -la"); // ❌ Geen child processes
process.env.DATABASE_URL; // ⚠️ Beschikbaar maar statisch, ingesteld bij deployWat je WEL hebt aan de edge is het Web API-oppervlak — dezelfde API's die beschikbaar zijn in een browser:
// Deze werken allemaal aan de 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());De beperkingen zijn reëel en hard:
- Geheugen: 128MB per isolate (Cloudflare Workers), 256MB op sommige platforms
- CPU-tijd: 10-50ms aan daadwerkelijke CPU-tijd (niet kloktijd —
await fetch()telt niet mee, maarJSON.parse()op een 5MB-payload wel) - Geen native modules: Alles wat een C++-binding nodig heeft (bcrypt, sharp, canvas) kan niet
- Geen persistente verbindingen: Je kunt geen databaseverbinding open houden tussen verzoeken
- Bundlegrootte-limieten: Meestal 1-5MB voor het hele worker-script
Dit is niet Node.js op een CDN. Het is een andere runtime met een ander mentaal model.
Cold Starts: De Mythe en de Werkelijkheid#
Je hebt waarschijnlijk gehoord dat edge functions "nul cold starts" hebben. Dit is... grotendeels waar, en de vergelijking is werkelijk dramatisch.
Een traditionele container-gebaseerde serverless function (AWS Lambda, Google Cloud Functions) werkt als volgt:
- Verzoek komt binnen
- Platform provisioneert een container (als er geen beschikbaar is)
- Container start het besturingssysteem op
- Runtime initialiseert (Node.js, Python, etc.)
- Je code wordt geladen en geïnitialiseerd
- Verzoek wordt verwerkt
Stap 2-5 is de cold start. Voor een Node.js Lambda is dit meestal 200-500ms. Voor een Java Lambda kan het 2-5 seconden zijn. Voor een .NET Lambda 500ms-1,5s.
V8 isolates werken anders:
- Verzoek komt binnen
- Platform creëert een nieuwe V8 isolate (of hergebruikt een warme)
- Je code wordt geladen (het is al gecompileerd naar bytecode bij deploy-tijd)
- Verzoek wordt verwerkt
Stap 2-3 duurt minder dan 5ms. Vaak minder dan 1ms. De isolate is geen container — er is geen OS om op te starten, geen runtime om te initialiseren. V8 creëert een verse isolate in microseconden. De uitdrukking "nul cold start" is marketingtaal, maar de werkelijkheid (sub-5ms opstarttijd) komt dicht genoeg bij nul dat het voor de meeste use cases niet uitmaakt.
Maar hier is wanneer cold starts je aan de edge wél bijten:
Grote bundles. Als je edge function 2MB aan dependencies binnenhaalt, moet die code nog steeds worden geladen en geparsed. Ik leerde dit op de harde manier toen ik een validatiebibliotheek en een datum-opmaakbibliotheek bundelde in een edge middleware. De cold start ging van 2ms naar 40ms. Nog steeds snel, maar niet "nul."
Zeldzame locaties. Edge providers hebben honderden PoP's, maar niet alle PoP's houden je code warm. Als je één verzoek per uur krijgt uit Nairobi, wordt die isolate tussentijds gerecycled. Het volgende verzoek betaalt opnieuw de opstarttijd.
Meerdere isolates per verzoek. Als je edge function een andere edge function aanroept (of als middleware en een API-route beide edge zijn), kun je meerdere isolates draaien voor één gebruikersverzoek.
Het praktische advies: houd je edge function-bundles klein. Importeer alleen wat je nodig hebt. Tree-shake agressief. Hoe kleiner de bundle, hoe sneller de cold start, hoe beter de belofte van "nul cold start" standhoudt.
// ❌ Doe dit niet aan de edge
import dayjs from "dayjs";
import * as yup from "yup";
import lodash from "lodash";
// ✅ Doe dit in plaats daarvan — gebruik ingebouwde API's
const date = new Date().toISOString();
const isValid = typeof input === "string" && input.length < 200;
const unique = [...new Set(items)];Perfecte Use Cases voor Edge Functions#
Na uitgebreid experimenteren heb ik een duidelijk patroon gevonden: edge functions blinken uit wanneer je een snelle beslissing moet nemen over een verzoek voordat het je origin-server bereikt. Het zijn poortwachters, routers en transformatoren — geen applicatieservers.
1. Geolocatiegebaseerde Redirects#
Dit is de killer use case. Het verzoek bereikt de dichtstbijzijnde edge node, die al weet waar de gebruiker zich bevindt. Geen API-aanroep nodig, geen IP-lookupdatabase — het platform levert de geodata:
// middleware.ts — draait aan de edge bij elk verzoek
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 naar landspecifieke winkel
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));
}
}
// Voeg geo-headers toe voor downstream-gebruik
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;
}Dit draait in minder dan 5ms, vlak bij de gebruiker. Het alternatief — het verzoek helemaal naar je origin-server sturen alleen maar om een IP-lookup te doen en terug te redirecten — zou 100-300ms kosten voor gebruikers ver van je origin.
2. A/B-testen Zonder Client-flikkering#
Client-side A/B-testen veroorzaakt de gevreesde "flash of original content" — de gebruiker ziet versie A voor een fractie van een seconde voordat JavaScript versie B inlaadt. Aan de edge kun je de variant toewijzen voordat de pagina überhaupt begint met renderen:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Controleer of de gebruiker al een variant toegewezen heeft gekregen
const existingVariant = request.cookies.get("ab-variant")?.value;
if (existingVariant) {
// Herschrijf naar de juiste variantpagina
const url = request.nextUrl.clone();
url.pathname = `/variants/${existingVariant}${url.pathname}`;
return NextResponse.rewrite(url);
}
// Wijs een nieuwe variant toe (50/50 verdeling)
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 dagen
httpOnly: true,
sameSite: "lax",
});
return response;
}De gebruiker ziet nooit een flikkering omdat de rewrite op netwerkniveau plaatsvindt. De browser weet niet eens dat het een A/B-test was — hij ontvangt simpelweg direct de variantpagina.
3. Auth Token Verificatie#
Als je authenticatie JWTs gebruikt (en je geen databasesessie-lookups doet), is de edge perfect. JWT-verificatie is pure cryptografie — geen database nodig:
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",
});
// Geef gebruikersinfo door als 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 verlopen of ongeldig
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("session-token");
return response;
}
}Dit patroon is krachtig: de edge middleware verifieert het token en geeft gebruikersinformatie door aan je origin als vertrouwde headers. Je API-routes hoeven het token niet opnieuw te verifiëren — ze lezen gewoon request.headers.get("x-user-id").
4. Botdetectie en Rate Limiting#
Edge functions kunnen ongewenst verkeer blokkeren voordat het ooit je origin bereikt:
import { NextRequest, NextResponse } from "next/server";
// Eenvoudige in-memory rate limiter (per edge-locatie)
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") ?? "";
// Blokkeer bekende slechte bots
const badBots = ["AhrefsBot", "SemrushBot", "MJ12bot", "DotBot"];
if (badBots.some((bot) => ua.includes(bot))) {
return new NextResponse("Forbidden", { status: 403 });
}
// Eenvoudige rate limiting
const now = Date.now();
const windowMs = 60_000; // 1 minuut
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 });
}
// Periodieke opruiming om geheugenlekken te voorkomen
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();
}Eén kanttekening: de rate limit-map hierboven is per isolate, per locatie. Als je 300 edge-locaties hebt, heeft elke locatie zijn eigen map. Voor strikte rate limiting heb je een gedistribueerde opslag nodig zoals Upstash Redis of Cloudflare Durable Objects. Maar voor grove misbruikpreventie werken per-locatie limieten verrassend goed.
5. Request Rewriting en Personalisatie-headers#
Edge functions zijn uitstekend in het transformeren van verzoeken voordat ze je origin bereiken:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const url = request.nextUrl;
// Device-gebaseerde 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 vanuit cookie
const flags = request.cookies.get("feature-flags")?.value;
if (flags) {
response.headers.set("x-feature-flags", flags);
}
// Locale-detectie voor 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;
}Waar de Edge Faalt#
Dit is het gedeelte dat de marketingpagina's overslaan. Ik ben tegen al deze muren aangelopen.
1. Databaseverbindingen#
Dit is het grote probleem. Traditionele databases (PostgreSQL, MySQL) gebruiken persistente TCP-verbindingen. Een Node.js-server opent bij het opstarten een connection pool en hergebruikt die verbindingen over verzoeken heen. Efficiënt, bewezen, goed begrepen.
Edge functions kunnen dit niet. Elke isolate is vluchtig. Er is geen "opstart"-fase waarin je verbindingen opent. Zelfs als je een verbinding zou kunnen openen, kan de isolate na één verzoek worden gerecycled, waardoor de verbindingsopbouwtijd verspild is.
// ❌ Dit patroon werkt fundamenteel niet aan de edge
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // Connection pool van 10
});
// Elke edge-aanroep zou:
// 1. Een nieuwe Pool aanmaken (kan niet betrouwbaar hergebruikt worden tussen aanroepen)
// 2. Een TCP-verbinding openen naar je database (die in us-east-1 staat, niet aan de edge)
// 3. Een TLS-handshake doen met de database
// 4. De query uitvoeren
// 5. De verbinding weggooien wanneer de isolate wordt gerecycled
// Zelfs met connection pooling-services zoals PgBouncer
// betaal je nog steeds de netwerklatency van edge → origin databaseHet database-roundtripprobleem is fundamenteel. Je database staat in één regio. Je edge function draait in 300 regio's. Elke databasequery vanaf de edge moet reizen van de edge-locatie naar de databaseregio en terug. Voor een gebruiker in Tokio die een Tokio edge node bereikt, maar je database staat in Virginia:
Edge function in Tokio
→ Query naar PostgreSQL in Virginia: ~140ms roundtrip
→ Tweede query: ~140ms extra
→ Totaal: 280ms alleen voor twee queries
Node.js-functie in Virginia (dezelfde regio als DB)
→ Query naar PostgreSQL: ~1ms roundtrip
→ Tweede query: ~1ms extra
→ Totaal: 2ms voor twee queries
De edge function is 140x langzamer voor databaseoperaties in dit scenario. Het maakt niet uit dat de edge function sneller opstartte — de database-roundtrips domineren alles.
Dit is waarom HTTP-gebaseerde database-proxy's bestaan (Neon's serverless driver, PlanetScale's fetch-gebaseerde driver, Supabase's REST API). Ze werken, maar ze maken nog steeds HTTP-verzoeken naar een database in een enkele regio. Ze lossen het "kan geen TCP gebruiken"-probleem op, maar niet het "database is ver weg"-probleem.
// ✅ Dit werkt aan de edge (HTTP-gebaseerde databasetoegang)
// Maar het is nog steeds traag als de database ver van de edge node staat
import { neon } from "@neondatabase/serverless";
export const runtime = "edge";
export async function GET(request: Request) {
const sql = neon(process.env.DATABASE_URL!);
// Dit maakt een HTTP-verzoek naar je Neon-database
// Werkt, maar latency hangt af van de afstand tot de databaseregio
const posts = await sql`SELECT * FROM posts WHERE published = true LIMIT 10`;
return Response.json(posts);
}2. Langlopende Taken#
Edge functions hebben CPU-tijdlimieten, meestal 10-50ms aan daadwerkelijke rekentijd. Kloktijd is ruimer (meestal 30 seconden), maar CPU-intensieve operaties bereiken de limiet snel:
// ❌ Deze zullen de CPU-tijdlimieten aan de edge overschrijden
export const runtime = "edge";
export async function POST(request: Request) {
const data = await request.json();
// Beeldverwerking — CPU-intensief
// (Kan ook sharp niet gebruiken omdat het een native module is)
const processed = heavyImageProcessing(data.image);
// PDF-generatie — CPU-intensief + heeft Node.js-API's nodig
const pdf = generatePDF(data.content);
// Grote datatransformatie
const result = data.items // 100.000 items
.map(transform)
.filter(validate)
.sort(compare)
.reduce(aggregate, {});
return Response.json(result);
}Als je functie meer dan een paar milliseconden CPU-tijd nodig heeft, hoort die thuis op een regionale Node.js-server. Punt.
3. Alleen-Node.js Dependencies#
Deze vangt mensen onverwacht. Een verrassend aantal npm-packages zijn afhankelijk van Node.js ingebouwde modules:
// ❌ Deze packages werken niet aan de edge
import bcrypt from "bcrypt"; // Native C++-binding
import sharp from "sharp"; // Native C++-binding
import puppeteer from "puppeteer"; // Heeft bestandssysteem + child_process nodig
import nodemailer from "nodemailer"; // Heeft net-module nodig
import { readFile } from "fs/promises"; // Node.js bestandssysteem-API
import mongoose from "mongoose"; // TCP-verbindingen + Node.js-API's
// ✅ Edge-compatibele alternatieven
import { hashSync } from "bcryptjs"; // Pure JS-implementatie (langzamer)
// Voor afbeeldingen: gebruik een aparte service of API
// Voor e-mail: gebruik een HTTP-gebaseerde e-mail-API (Resend, SendGrid REST)
// Voor database: gebruik HTTP-gebaseerde clientsVoordat je iets naar de edge verplaatst, controleer elke dependency. Eén require("fs") drie niveaus diep in je dependency-tree zal je edge function laten crashen tijdens runtime — niet tijdens build-tijd. Je deployt, alles ziet er prima uit, dan bereikt het eerste verzoek dat codepad en krijg je een cryptische fout.
4. Grote Bundlegroottes#
Edge-platforms hebben strikte bundlegrootte-limieten:
- Cloudflare Workers: 1MB (gratis), 5MB (betaald)
- Vercel Edge Functions: 4MB (gecomprimeerd)
- Deno Deploy: 20MB
Dit klinkt als genoeg totdat je een UI-componentenbibliotheek, een validatiebibliotheek en een datumbibliotheek importeert. Ik had ooit een edge middleware die opzwol tot 3,5MB omdat ik importeerde vanuit een barrel file die de hele @/components-directory binnentrok.
// ❌ Barrel file-imports kunnen veel te veel binnentrekken
import { validateEmail } from "@/lib/utils";
// Als utils.ts herexporteert vanuit 20 andere modules, worden ze allemaal gebundeld
// ✅ Importeer direct vanuit de bron
import { validateEmail } from "@/lib/validators/email";5. Streaming en WebSockets#
Edge functions kunnen streaming responses doen (Web Streams API), maar langlevende WebSocket-verbindingen zijn een ander verhaal. Hoewel sommige platforms WebSockets aan de edge ondersteunen (Cloudflare Workers, Deno Deploy), maakt de vluchtige aard van edge functions ze een slechte keuze voor stateful, langlevende verbindingen.
Next.js Edge Runtime#
Next.js maakt het eenvoudig om per route voor de edge runtime te kiezen. Je hoeft niet alles om te zetten — je kiest precies welke routes aan de edge draaien.
Middleware (Altijd Edge)#
Next.js middleware draait altijd aan de edge. Dit is by design — middleware onderschept elk matchend verzoek, dus het moet snel zijn en wereldwijd gedistribueerd:
// middleware.ts — draait altijd aan de edge, geen opt-in nodig
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Dit draait vóór elk matchend verzoek
// Houd het snel — geen databaseaanroepen, geen zware berekeningen
return NextResponse.next();
}
export const config = {
// Draai alleen op specifieke paden
matcher: [
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
],
};API-routes aan de Edge#
Elke route handler kan kiezen voor de edge runtime:
// app/api/hello/route.ts
export const runtime = "edge"; // Deze ene regel verandert de runtime
export async function GET(request: Request) {
return Response.json({
message: "Hello from the edge",
region: process.env.VERCEL_REGION ?? "unknown",
timestamp: Date.now(),
});
}Pagina-routes aan de Edge#
Zelfs hele pagina's kunnen renderen aan de edge, hoewel ik hier goed over zou nadenken:
// app/dashboard/page.tsx
export const runtime = "edge";
export default async function DashboardPage() {
// Onthoud: geen Node.js-API's hier
// Alle data-fetching moet via fetch() of edge-compatibele 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>
);
}Wat Beschikbaar Is in de Edge Runtime#
Hier is een praktische referentie van wat je wel en niet kunt gebruiken:
// ✅ Beschikbaar aan de edge
fetch() // HTTP-verzoeken
Request / Response // Web-standaard request/response
Headers // HTTP-headers
URL / URLSearchParams // URL-parsing
TextEncoder / TextDecoder // String-encoding
crypto.subtle // Crypto-operaties (ondertekenen, hashen)
crypto.randomUUID() // UUID-generatie
crypto.getRandomValues() // Cryptografische willekeurige getallen
structuredClone() // Deep cloning
atob() / btoa() // Base64-encoding/decoding
setTimeout() / setInterval() // Timers (maar onthoud CPU-limieten)
console.log() // Logging
ReadableStream / WritableStream // Streaming
AbortController / AbortSignal // Verzoekannulering
URLPattern // URL-patroonmatching
// ❌ NIET beschikbaar aan de edge
require() // CommonJS (gebruik import)
fs / path / os // Node.js ingebouwde modules
process.exit() // Procescontrole
Buffer // Gebruik Uint8Array in plaats daarvan
__dirname / __filename // Gebruik import.meta.url
setImmediate() // Geen webstandaardAuth aan de Edge: Het Volledige Patroon#
Ik wil dieper ingaan op authenticatie omdat het een van de meest impactvolle edge use cases is, maar het is ook makkelijk om het verkeerd te doen.
Het patroon dat werkt is: verifieer het token aan de edge, geef vertrouwde claims door naar downstream, raak nooit de database aan in middleware.
// lib/edge-auth.ts — Edge-compatibele auth-hulpmiddelen
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 seconden klokverschiltolerantie
});
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 — De 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;
// Sla auth over voor publieke paden
if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith("/api/public"))) {
return NextResponse.next();
}
// Haal token op
const token = request.cookies.get("auth-token")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Verifieer token (pure cryptografie — geen databaseaanroep)
const payload = await verifyToken(token);
if (!payload) {
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("auth-token");
return response;
}
// Rolgebaseerde toegangscontrole
if (ADMIN_PATHS.some((p) => pathname.startsWith(p)) && payload.role !== "admin") {
return NextResponse.redirect(new URL("/unauthorized", request.url));
}
// Geef geverifieerde gebruikersinfo door aan de origin als vertrouwde 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);
// Geef een signaal als het token vernieuwd moet worden
if (isTokenExpiringSoon(payload)) {
response.headers.set("x-token-refresh", "true");
}
return response;
}// app/api/profile/route.ts — Origin-server leest vertrouwde headers
export async function GET(request: Request) {
// Deze headers zijn ingesteld door edge middleware na JWT-verificatie
// Ze zijn vertrouwd omdat ze van onze eigen infrastructuur komen
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 });
}
// Nu kunnen we de database benaderen — we zijn op de origin-server,
// vlak naast de database, met een connection pool
const user = await db.user.findUnique({ where: { id: userId } });
return Response.json(user);
}Het kernpunt: de edge doet het snelle deel (cryptoverificatie), en de origin doet het trage deel (databasequeries). Elk draait waar het het meest efficiënt is.
Eén belangrijke kanttekening: dit werkt alleen voor JWTs. Als je authenticatiesysteem een databaselookup vereist bij elk verzoek (zoals sessiegebaseerde auth met een sessie-ID-cookie), kan de edge niet helpen — je zou nog steeds de database moeten aanroepen, wat een roundtrip naar de origin-regio betekent.
Edge Caching#
Caching aan de edge is waar het echt interessant wordt. Edge nodes kunnen responses cachen, wat betekent dat opvolgende verzoeken naar dezelfde URL direct vanaf de edge worden geserveerd zonder je origin te benaderen.
Cache-Control Correct Instellen#
// 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 op CDN voor 60 seconden
// Serveer verouderd terwijl er opnieuw gevalideerd wordt tot 5 minuten
// Client kan 10 seconden cachen
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300, max-age=10",
// Varieer op deze headers zodat verschillende varianten verschillende cache-items krijgen
Vary: "Accept-Language, Accept-Encoding",
// CDN-specifieke cache tag voor gerichte invalidatie
"Cache-Tag": `products,category-${category}`,
},
});
}Het stale-while-revalidate-patroon is bijzonder krachtig aan de edge. Dit is wat er gebeurt:
- Eerste verzoek: Edge haalt op bij de origin, cachet de response, retourneert deze
- Verzoeken binnen 60 seconden: Edge serveert vanuit cache (0ms origin-latency)
- Verzoek na 61-360 seconden: Edge serveert de verouderde gecachte versie direct, maar haalt op de achtergrond een verse versie op bij de origin
- Na 360 seconden: Cache is volledig verlopen, volgende verzoek gaat naar de origin
Je gebruikers krijgen bijna altijd een gecachte response. De afweging qua versheid is expliciet en afstelbaar.
Edge Config voor Dynamische Configuratie#
Vercel's Edge Config (en vergelijkbare services van andere platforms) laat je key-value-configuratie opslaan die naar elke edge-locatie wordt gerepliceerd. Dit is ongelooflijk nuttig voor feature flags, redirectregels en A/B-testconfiguratie die je wilt bijwerken zonder opnieuw te deployen:
import { get } from "@vercel/edge-config";
import { NextRequest, NextResponse } from "next/server";
export async function middleware(request: NextRequest) {
// Edge Config-reads zijn extreem snel (~1ms) omdat
// de data naar elke edge-locatie wordt gerepliceerd
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));
}
// Dynamische redirects (werk redirects bij zonder 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();
}Dit is een echte gamechanger. Vóór Edge Config betekende het wijzigen van een feature flag een codewijziging en redeploy. Nu werk je een JSON-waarde bij in een dashboard en het verspreidt zich wereldwijd binnen seconden.
De Echte Prestatieberekening#
Laten we de eerlijke berekening doen in plaats van de marketingberekening. Ik vergelijk drie architecturen voor een typisch API-endpoint dat de database moet queryen:
Scenario: Gebruikersprofiel-API (2 databasequeries)#
Architectuur A: Traditioneel Regionaal Node.js
Gebruiker in Tokio → Origin in Virginia: 140ms
+ DB-query 1 (dezelfde regio): 2ms
+ DB-query 2 (dezelfde regio): 2ms
+ Verwerking: 5ms
= Totaal: ~149ms
Architectuur B: Edge Function met HTTP-database
Gebruiker in Tokio → Edge in Tokio: 5ms
+ DB-query 1 (HTTP naar Virginia): 145ms
+ DB-query 2 (HTTP naar Virginia): 145ms
+ Verwerking: 3ms
= Totaal: ~298ms ← LANGZAMER dan regionaal
Architectuur C: Edge Function met Regionale Database (read replica)
Gebruiker in Tokio → Edge in Tokio: 5ms
+ DB-query 1 (HTTP naar Tokio-replica): 8ms
+ DB-query 2 (HTTP naar Tokio-replica): 8ms
+ Verwerking: 3ms
= Totaal: ~24ms ← Snelst, maar vereist multi-regio DB
Architectuur D: Edge voor Auth + Regionaal voor Data
Gebruiker in Tokio → Edge middleware in Tokio: 5ms (JWT-verificatie)
→ Origin in Virginia: 140ms
+ DB-query 1 (dezelfde regio): 2ms
+ DB-query 2 (dezelfde regio): 2ms
+ Verwerking: 5ms
= Totaal: ~154ms
(Maar auth is al geverifieerd — origin hoeft niet opnieuw te verifiëren)
(En ongeautoriseerde verzoeken worden aan de edge geblokkeerd — bereiken nooit de origin)
De conclusies:
- Edge + origin-database = vaak langzamer dan gewoon een regionale server gebruiken
- Edge + multi-regio database = snelst maar duurder en complexer
- Edge voor poortwacht + regionaal voor data = beste pragmatische balans
- Pure edge (geen database) = onverslaanbaar voor dingen als redirects en auth-checks
Architectuur D is wat ik gebruik voor de meeste projecten. De edge handelt af waar het goed in is (snelle beslissingen, auth, routing), en de regionale Node.js-server handelt af waar die goed in is (databasequeries, zware berekeningen).
Wanneer de Edge Echt Wint: Operaties Zonder Database#
De berekening slaat volledig om wanneer er geen database bij betrokken is:
Redirect (edge):
Gebruiker in Tokio → Edge in Tokio → redirect-response: ~5ms
Redirect (regionaal):
Gebruiker in Tokio → Origin in Virginia → redirect-response: ~280ms
Statische API-response (edge + cache):
Gebruiker in Tokio → Edge in Tokio → gecachte response: ~5ms
Statische API-response (regionaal):
Gebruiker in Tokio → Origin in Virginia → response: ~280ms
Bot blokkeren (edge):
Slechte bot van overal → Edge (dichtstbijzijnd) → 403-response: ~5ms
(Bot bereikt nooit je origin-server)
Bot blokkeren (regionaal):
Slechte bot van overal → Origin in Virginia → 403-response: ~280ms
(Bot heeft nog steeds origin-resources verbruikt)
Voor operaties die geen database nodig hebben, is de edge 20-50x sneller. Dit is geen marketing — het is fysica.
Mijn Beslissingskader#
Na een jaar werken met edge functions in productie, is dit het stroomschema dat ik gebruik voor elk nieuw endpoint of stuk logica:
Stap 1: Heeft het Node.js-API's nodig?#
Als het fs, net, child_process of een native module importeert — Node.js regionaal. Geen discussie.
Stap 2: Heeft het databasequeries nodig?#
Zo ja, en je hebt geen read replica's in de buurt van je gebruikers — Node.js regionaal (in dezelfde regio als je database). De database-roundtrips zullen domineren.
Zo ja, en je hebt wereldwijd gedistribueerde read replica's — Edge kan werken, met HTTP-gebaseerde databaseclients.
Stap 3: Is het een beslissing over een verzoek (routing, auth, redirect)?#
Zo ja — Edge. Dit is de sweet spot. Je neemt een snelle beslissing die bepaalt wat er met het verzoek gebeurt voordat het de origin bereikt.
Stap 4: Is de response cachebaar?#
Zo ja — Edge met juiste Cache-Control-headers. Zelfs als het eerste verzoek naar je origin gaat, worden opvolgende verzoeken vanuit de edge-cache geserveerd.
Stap 5: Is het CPU-intensief?#
Als het significante berekeningen omvat (beeldverwerking, PDF-generatie, grote datatransformaties) — Node.js regionaal.
Stap 6: Hoe latencygevoelig is het?#
Als het een achtergrondtaak of webhook is — Node.js regionaal. Niemand zit erop te wachten. Als het een gebruikersgericht verzoek is waarbij elke ms telt — Edge, als het aan de andere criteria voldoet.
Het Spiekbriefje#
// ✅ PERFECT voor edge
// - Middleware (auth, redirects, rewrites, headers)
// - Geolocatielogica
// - A/B-testtoewijzing
// - Botdetectie / WAF-regels
// - Cache-vriendelijke API-responses
// - Feature flag-checks
// - CORS-preflight responses
// - Statische datatransformaties (geen DB)
// - Webhook-handtekeningverificatie
// ❌ HOUD op Node.js regionaal
// - Database CRUD-operaties
// - Bestandsuploads / -verwerking
// - Beeldmanipulatie
// - PDF-generatie
// - E-mail versturen (gebruik HTTP API, maar nog steeds regionaal)
// - WebSocket-servers
// - Achtergrondtaken / wachtrijen
// - Alles dat native npm-packages gebruikt
// - SSR-pagina's met databasequeries
// - GraphQL-resolvers die databases benaderen
// 🤔 HET HANGT ERVAN AF
// - Authenticatie (edge voor JWT, regionaal voor sessie-DB)
// - API-routes (edge als geen DB, regionaal als DB)
// - Server-gerenderde pagina's (edge als data van cache/fetch, regionaal als DB)
// - Realtime-functies (edge voor initiële auth, regionaal voor persistente verbindingen)Wat Ik Daadwerkelijk aan de Edge Draai#
Voor deze site is dit de verdeling:
Edge (middleware):
- Locale-detectie en redirect
- Botfiltering
- Beveiligingsheaders (CSP, HSTS, etc.)
- Toegangslogging
- Rate limiting (basis)
Node.js regionaal:
- Blogcontent renderen (MDX-verwerking heeft Node.js-API's nodig via Velite)
- API-routes die Redis benaderen
- OG-beeldgeneratie (heeft meer CPU-tijd nodig)
- RSS-feedgeneratie
Statisch (helemaal geen runtime):
- Toolpagina's (vooraf gerenderd bij build-tijd)
- Blogpostpagina's (vooraf gerenderd bij build-tijd)
- Alle afbeeldingen en assets (CDN-geserveerd)
De beste runtime is vaak geen runtime. Als je iets kunt pre-renderen bij build-tijd en als statisch asset kunt serveren, zal dat altijd sneller zijn dan welke edge function dan ook. De edge is voor de dingen die echt dynamisch moeten zijn bij elk verzoek.
De Eerlijke Samenvatting#
Edge functions zijn geen vervanging voor traditionele servers. Ze zijn een aanvulling. Ze zijn een extra tool in je architectuurtoolbox — een die ongelooflijk krachtig is voor de juiste use cases en actief schadelijk voor de verkeerde.
De vuistregel waar ik steeds op terugkom: als je functie een database moet benaderen in een enkele regio, helpt het niet om de functie aan de edge te plaatsen — het werkt juist tegen je. Je hebt net een hop toegevoegd. De functie draait sneller, maar besteedt vervolgens 100ms+ aan het terugbereiken naar de database. Nettoresultaat: langzamer dan alles in één regio draaien.
Maar voor beslissingen die genomen kunnen worden met alleen de informatie in het verzoek zelf — geolocatie, cookies, headers, JWTs — is de edge onverslaanbaar. Die 5ms edge-responses zijn geen synthetische benchmarks. Ze zijn echt, en je gebruikers voelen het verschil.
Verplaats niet alles naar de edge. Houd ook niet alles weg van de edge. Zet elk stuk logica waar de fysica het begunstigt.