Eine Rate-Limiter-Middleware in Express bauen
Ein ungeschützter API-Endpunkt ist eine Einladung: ein fehlerhaftes Client-Skript, eine Retry-Schleife ohne Backoff, ein Scraper, und dein Server verbringt seine ganze Zeit damit, dieselben paar Clients zu bedienen statt alle anderen. Rate Limiting löst das, indem es begrenzt, wie viele Anfragen ein Client in einem Zeitfenster stellen darf – und eine solide Variante baust du selbst in etwa 40 Zeilen, ganz ohne Abhängigkeit.
Was „Rate Limiting" eigentlich bedeutet
Ein Rate Limiter zählt die Anfragen pro Client (meist anhand von IP-Adresse oder API-Key) und lehnt Anfragen ab, sobald dieser Client innerhalb eines Zeitfensters einen Schwellenwert überschreitet – etwa „100 Anfragen pro Minute". Sobald das Fenster abgelaufen ist, wird der Zähler zurückgesetzt und der Client darf wieder Anfragen stellen.
Die einfachste korrekte Implementierung ist der Fixed-Window-Zähler: Für jeden Client werden ein Zähler und ein Zeitstempel für den Fensterbeginn gehalten. Bei jeder Anfrage wird hochgezählt; läuft das Fenster ab, werden beide zurückgesetzt.
Ein minimaler Fixed-Window-Limiter
function createRateLimiter({ windowMs, max }) {
const clients = new Map();
return function rateLimiter(req, res, next) {
const key = req.ip;
const now = Date.now();
const entry = clients.get(key);
if (!entry || now - entry.windowStart >= windowMs) {
clients.set(key, { windowStart: now, count: 1 });
return next();
}
if (entry.count >= max) {
const retryAfterMs = windowMs - (now - entry.windowStart);
res.setHeader('Retry-After', Math.ceil(retryAfterMs / 1000));
return res.status(429).json({ error: 'Too many requests' });
}
entry.count += 1;
next();
};
}
Einbindung in eine Express-App:
const express = require('express');
const app = express();
const limiter = createRateLimiter({ windowMs: 60_000, max: 100 });
app.use('/api/', limiter);
app.get('/api/posts', (req, res) => {
res.json({ posts: [] });
});
app.listen(3000);
Jeder Client bekommt 100 Anfragen pro gleitendem 60-Sekunden-Fenster, bevor er ein 429 Too Many Requests sieht, mit einem Retry-After-Header, der ihm sagt, wann er wiederkommen soll.
Das Fixed-Window-Problem: Bursts an der Fenstergrenze
Feste Fenster haben eine bekannte Schwäche: Ein Client kann in der letzten Sekunde eines Fensters max Anfragen stellen und direkt in der ersten Sekunde des nächsten Fensters noch einmal max – die doppelte beabsichtigte Rate, konzentriert in einem Zwei-Sekunden-Burst genau an der Grenze.
window 1: [ ......................############] <- 100 requests, last second
window 2: [############......................] <- 100 more, first second
Für die meisten APIs ist das nicht besonders relevant. Falls doch, ist die Lösung ein Sliding-Window-Log: Statt eines Zählers pro Client speichert man den Zeitstempel jeder Anfrage im Fenster und zählt, wie viele davon in now - windowMs fallen.
function createSlidingWindowLimiter({ windowMs, max }) {
const clients = new Map();
return function rateLimiter(req, res, next) {
const key = req.ip;
const now = Date.now();
const timestamps = clients.get(key) ?? [];
const windowStart = now - windowMs;
const recent = timestamps.filter((t) => t > windowStart);
if (recent.length >= max) {
clients.set(key, recent);
res.setHeader('Retry-After', Math.ceil((recent[0] - windowStart) / 1000));
return res.status(429).json({ error: 'Too many requests' });
}
recent.push(now);
clients.set(key, recent);
next();
};
}
Das ist exakt, kostet aber mehr Speicher (ein Zeitstempel pro Anfrage und Client statt eines Zählers). Für APIs mit viel Traffic bringt ein Hybrid – der Sliding-Window-Zähler, der die Anzahl des vorherigen Fensters danach gewichtet, wie stark es das aktuelle überlappt – dich nahe ans exakte Verhalten bei den Speicherkosten fester Fenster. Das ist mehr Mathematik als Code, weshalb die meisten Teams zu einer Bibliothek oder einem Limiter auf Proxy-Ebene greifen (nginx' limit_req oder einer mit Redis), sobald sie das im großen Maßstab brauchen.
Den Schlüssel fürs Rate Limiting wählen
req.ip ist der naheliegende Schlüssel, versagt aber hinter einem geteilten NAT (ein Büro voller Entwickler mit einer gemeinsamen öffentlichen IP) oder einem Proxy, der die echte Client-Adresse nicht weiterreicht. Zwei Anpassungen zählen in der Praxis:
- Dem Proxy korrekt vertrauen. Hinter nginx oder einem Load Balancer ist
req.ipdie Adresse des Proxys, es sei denn, man konfiguriert Express, ihm zu vertrauen:app.set('trust proxy', 1)undreq.ipausX-Forwarded-Forlesen. Macht man das falsch, werden alle Clients als einer gedrosselt. - Einen API-Key einer IP vorziehen, wo vorhanden. Authentifizierte Endpunkte sollten pro API-Key oder Nutzer-ID drosseln, nicht pro IP – das ist die Identität, die tatsächlich zählt, und sie übersteht IP-Wechsel und geteilte Netzwerke.
Veraltete Einträge aufräumen
Die Map in beiden Implementierungen oben wächst ewig, wenn man alte Einträge nicht entfernt – ein langsames Speicherleck in einem langlaufenden Prozess. Ein einfacher periodischer Durchlauf löst das:
setInterval(() => {
const cutoff = Date.now() - windowMs;
for (const [key, entry] of clients) {
if (entry.windowStart < cutoff) clients.delete(key);
}
}, windowMs).unref();
Das .unref() ist in Node wichtig: Es sagt der Event-Loop, den Prozess nicht allein wegen dieses Timers am Leben zu halten, damit er ein geordnetes Herunterfahren nicht stört.
Wann stattdessen eine Bibliothek sinnvoll ist
express-rate-limit implementiert all das (plus IPv6-Behandlung und austauschbare Stores) und ist der richtige Standard für eine App mit einem einzigen Prozess. Sobald du mehrere Instanzen hinter einem Load Balancer betreibst, funktioniert eine In-Memory-Map nicht mehr – jede Instanz hat ihre eigenen Zähler, sodass ein Client max Anfragen pro Instanz bekommt statt max insgesamt. Dann braucht man einen gemeinsamen Store, und express-rate-limit unterstützt genau deshalb einen mit Redis.
Fazit
Ein Rate Limiter ist im Kern ein Zähler mit angehängtem Zeitfenster – alles andere ist Verfeinerung. Beginne mit einem festen Fenster; es ist einfach, billig und für die meisten APIs korrekt genug. Wechsle zu einem Sliding Window erst, wenn Bursts an der Fenstergrenze ein tatsächlich gemessenes Problem sind, und wechsle zu einem gemeinsamen Store in dem Moment, in dem du mehr als einen Serverprozess betreibst.