Caching-Strategien für Webanwendungen
Von HTTP-Headern bis zu Redis-Mustern: ein praktischer Leitfaden zu Caching-Schichten, die Latenz und DB-Last senken, ohne veraltete Daten zu liefern.

Jede langsame Anwendung hat dasselbe Problem: Sie holt Daten, die sie bereits hat. Caching ist das wirksamste Performance-Werkzeug im Repertoire eines Backend-Engineers, aber falsches Caching ist schlimmer als gar kein Caching — es liefert veraltete Daten, erzeugt Konsistenz-Bugs und vermittelt ein falsches Gefühl von Performance.
Die Caching-Schichten
Moderne Webanwendungen können auf vier Ebenen cachen. Jede hat unterschiedliche Latenzeigenschaften und Invalidierungskomplexität.
| Ebene | Latenz | Invalidierung | Geeignet für |
|---|---|---|---|
| Browser-Cache (HTTP-Header) | 0ms | Wird über Header gesteuert | Statische Assets, API-Antworten |
| CDN-/Edge-Cache | 1-50ms | Purge-API oder TTL | Öffentliche, lesehäufige Inhalte |
| Anwendungs-Cache (Redis/Memory) | 1-5ms | Anwendungslogik | Sitzungsdaten, berechnete Ergebnisse |
| Datenbank-Query-Cache | 5-20ms | Automatisch (query-abhängig) | Wiederholte identische Queries |
Beginne von oben — Browser-Caching ist kostenlos und eliminiert Netzwerk-Requests vollständig. Füge tiefere Ebenen nur hinzu, wenn die oberen nicht ausreichen.
HTTP-Caching-Header
Der eingebaute Cache des Browsers ist die schnellste und günstigste Option. Zwei Header steuern das meiste davon.
// ❌ No cache headers — browser re-fetches every time
app.get("/api/products", async (req, res) => {
const products = await db.products.findMany();
res.json(products);
});
// ✅ Cache headers — browser reuses response for 60 seconds
app.get("/api/products", async (req, res) => {
const products = await db.products.findMany();
res.set("Cache-Control", "public, max-age=60, s-maxage=300");
res.set("ETag", computeETag(products));
res.json(products);
});max-age sagt dem Browser, wie lange er die gecachte Antwort ohne Revalidierung nutzen soll. s-maxage gibt CDNs und Proxys eine separate TTL vor. ETag ermöglicht bedingte Requests — der Browser sendet If-None-Match, und der Server kann 304 Not Modified zurückgeben, ohne den Body zu senden.
// Conditional request handling
app.get("/api/products", async (req, res) => {
const products = await db.products.findMany();
const etag = computeETag(products);
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
res.set("Cache-Control", "public, max-age=60");
res.set("ETag", etag);
res.json(products);
});Caching auf Anwendungsebene mit Redis
Für berechnete oder aggregierte Daten, deren Neuerzeugung teuer ist, ist Redis die Standardwahl. Das Muster heißt Cache-Aside (auch Lazy Loading genannt).
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
async function getProductCatalog(categoryId: string) {
const cacheKey = `catalog:${categoryId}`;
// 1. Check cache
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. Cache miss — fetch from database
const products = await db.products.findMany({
where: { categoryId, status: "active" },
include: { pricing: true, inventory: true },
});
// 3. Store in cache with TTL
await redis.set(cacheKey, JSON.stringify(products), "EX", 300);
return products;
}Das Cache-Aside-Muster ist einfach, hat aber ein Thundering-Herd-Problem: Wenn der Cache abläuft, treffen alle gleichzeitigen Requests zeitgleich auf die Datenbank.
Thundering Herds verhindern
Wenn ein populärer Cache-Key abläuft, versuchen möglicherweise Hunderte von Requests gleichzeitig, ihn neu aufzubauen. Ein Mutex (Lock) stellt sicher, dass nur einer die Arbeit erledigt, während die anderen warten.
async function getWithLock<T>(
key: string,
ttl: number,
fetchFn: () => Promise<T>,
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (acquired) {
try {
const data = await fetchFn();
await redis.set(key, JSON.stringify(data), "EX", ttl);
return data;
} finally {
await redis.del(lockKey);
}
}
// Another process holds the lock — wait and retry
await new Promise((resolve) => setTimeout(resolve, 100));
return getWithLock(key, ttl, fetchFn);
}Eine Alternative ist Stale-While-Revalidate: den abgelaufenen gecachten Wert sofort ausliefern, während im Hintergrund aktualisiert wird.
Strategien zur Cache-Invalidierung
Cache-Invalidierung ist wirklich schwierig. Hier sind die praktischen Ansätze:
// Strategy 1: TTL-based — simplest, accept brief staleness
await redis.set("catalog:electronics", data, "EX", 300); // 5 min
// Strategy 2: Event-driven — invalidate on write
async function updateProduct(id: string, updates: ProductUpdate) {
const product = await db.products.update({ where: { id }, data: updates });
// Invalidate related cache keys
await redis.del(`product:${id}`);
await redis.del(`catalog:${product.categoryId}`);
return product;
}
// Strategy 3: Versioned keys — never invalidate, always fresh
async function getProductV2(id: string) {
const version = await redis.get(`product-version:${id}`);
const cacheKey = `product:${id}:v${version}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const product = await db.products.findUnique({ where: { id } });
await redis.set(cacheKey, JSON.stringify(product), "EX", 3600);
return product;
}| Strategie | Konsistenz | Komplexität | Geeignet für |
|---|---|---|---|
| Nur TTL | Eventual (innerhalb der TTL) | Niedrig | Lesehäufig, Veralten akzeptabel |
| Ereignisgesteuert | Stark | Mittel | Write-after-Read-Muster |
| Versionierte Keys | Stark | Hoch | Hoher Traffic, muss aktuell sein |
Was man nicht cachen sollte
Nicht alles profitiert vom Caching. Vermeide das Cachen von:
- Nutzerspezifische Daten mit kurzen Sitzungen — die Cache-Trefferquote wird nahe null liegen
- Sich schnell ändernde Daten — die Kosten der Cache-Invalidierung übersteigen den Nutzen
- Daten mit strengen Konsistenzanforderungen — Finanztransaktionen, Bestandszahlen während des Checkouts
- Große, selten abgerufene Payloads — verschwenden Speicher für eine minimale Trefferquote
Die erste Frage vor dem Hinzufügen eines Caches sollte immer sein: Wie hoch wird die Trefferquote sein? Liegt sie unter 50 %, fügt der Cache wahrscheinlich Komplexität hinzu, ohne ausreichenden Nutzen zu bringen.
Die wichtigsten Punkte
- Beginne mit HTTP-Caching-Headern — sie sind kostenlos und eliminieren Netzwerk-Requests vollständig
- Cache-Aside mit Redis ist das Standardmuster für Caching auf Anwendungsebene
- Verhindere Thundering Herds mit Mutex-Locks oder Stale-While-Revalidate
- Wähle eine Invalidierungsstrategie basierend auf deinen Konsistenzanforderungen
- Miss die Cache-Trefferquoten — niedrige Trefferquoten bedeuten, dass du Komplexität ohne Nutzen hinzufügst


