Zum Inhalt springen

HTTP-Caching: Cache-Control, ETags und CDN-Strategien

Praxisnaher Leitfaden zu HTTP-Caching-Headern: was jede Direktive bewirkt, wann ETags sinnvoll sind und wie du aufhörst, gegen dein CDN zu kämpfen.

5 Min. Lesezeit
Diagramm der HTTP-Cache-Schichten vom Browser über das CDN bis zum Origin-Server

Die meisten Anwendungen behandeln HTTP-Caching als Nebensache — man streut einen Cache-Control: max-age=3600-Header über die statischen Assets und hakt das Thema ab. Dann geht ein Bug live, Nutzer sehen eine Stunde lang veraltete Daten, und plötzlich ist der Cache der Feind. Das Problem ist nicht das Caching. Das Problem ist, dass die meisten Entwickler nie ganz verstehen, was die Header tatsächlich steuern, welche Schichten sie betreffen und wie sie zusammenspielen.

Caching richtig hinzubekommen erfordert keinen CDN-Zauberer. Es erfordert, vier Konzepte gut zu verstehen: Direktiven, Validatoren, Frische und Geltungsbereich. Sobald die sitzen, kämpfst du nicht mehr gegen den Cache, sondern nutzt ihn.

Die Cache-Control-Direktiven, auf die es wirklich ankommt

Cache-Control ist eine kommaseparierte Liste von Direktiven, und die meisten Tutorials behandeln nur zwei oder drei der acht, die in Produktion zählen. Hier die realistische Entscheidungstabelle:

DirektiveSteuertWer sie beachtet
max-age=NFrischedauer in SekundenBrowser, CDNs, Proxies
s-maxage=NFrischedauer nur für CDNsCDNs, Shared Caches
no-cacheVor dem Ausliefern immer revalidierenAlle
no-storeNiemals cachenAlle
privateNur Browser, kein CDNCDNs (sie halten sich daran)
publicCDN und Browser dürfen beide cachenCDNs
must-revalidateNach Ablauf nichts Veraltetes ausliefernAlle
stale-while-revalidateVeraltetes ausliefern, im Hintergrund erneuernModerne Browser, CDNs

Das am häufigsten verwechselte Paar ist no-cache gegenüber no-store. no-cache heißt nicht „nicht cachen" — es heißt „cachen, aber vor dem Ausliefern validieren". no-store ist die echte „nicht cachen"-Direktive.

tstypescript
// ❌ Misused — this still caches the response
res.setHeader("Cache-Control", "no-cache");
 
// ✅ Correct usage — revalidate on every request
res.setHeader("Cache-Control", "no-cache, must-revalidate");
 
// ✅ Correct usage — truly no caching for sensitive data
res.setHeader("Cache-Control", "no-store, private");

Für nutzerspezifische API-Antworten ist private, no-cache meist die richtige Wahl. Für öffentliche, sich selten ändernde Daten wie Produktkataloge ist public, s-maxage=3600, stale-while-revalidate=86400 deutlich nützlicher.

ETags und bedingte Requests

max-age löst die Frische für bekannte TTLs. Aber was ist mit Inhalten, die sich unvorhersehbar ändern? ETags geben Clients einen Fingerabdruck, mit dem sie gegen den Origin validieren können, ohne unveränderte Inhalte erneut herunterzuladen.

Der Ablauf braucht zwei Runden. Beim ersten Request schickt der Server einen ETag-Header. Bei den folgenden schickt der Client If-None-Match mit diesem Wert. Der Server antwortet entweder mit 200 OK und einem neuen Body oder mit 304 Not Modified und leerem Body.

tstypescript
import { createHash } from "crypto";
 
function generateETag(data: string): string {
  return `"${createHash("sha256").update(data).digest("hex").slice(0, 16)}"`;
}
 
export async function GET(req: Request) {
  const data = await fetchProductCatalog();
  const body = JSON.stringify(data);
  const etag = generateETag(body);
 
  // Check conditional request header
  const ifNoneMatch = req.headers.get("if-none-match");
  if (ifNoneMatch === etag) {
    return new Response(null, {
      status: 304,
      headers: { ETag: etag },
    });
  }
 
  return new Response(body, {
    status: 200,
    headers: {
      "Content-Type": "application/json",
      "Cache-Control": "public, max-age=0, must-revalidate",
      ETag: etag,
    },
  });
}

Eine 304-Antwort trägt keinen Body — bei einem 100 KB großen JSON-Payload über eine langsame Verbindung ist das erheblich. ETags lohnen sich besonders bei Endpunkten, bei denen du nicht vorhersagen kannst, wie oft sich die Daten ändern, aber unnötige Übertragungen vermeiden willst, wenn sie es nicht getan haben.

~

Nutze schwache ETags (W/"hash"), wenn byteweise Identität keine Rolle spielt — etwa wenn die gzip-Kodierung zwischen Requests variieren kann. Für Range-Requests sind starke ETags Pflicht.

Stale-while-revalidate: die nützlichste Direktive, die niemand einsetzt

stale-while-revalidate kommt einem Gratis-Mittagessen im HTTP-Caching am nächsten. Sie sagt dem Cache: Liefere die veraltete Antwort sofort aus (null Latenz) und erneuere sie danach im Hintergrund.

tstypescript
// ❌ Binary choice — fresh or wait for origin
res.setHeader("Cache-Control", "public, max-age=60");
 
// ✅ Serve immediately, refresh in the background
res.setHeader(
  "Cache-Control",
  "public, max-age=60, stale-while-revalidate=600"
);

Mit dem zweiten Header ist die Antwort nach 60 Sekunden „veraltet", wird aber weiterhin sofort ausgeliefert. Der Cache revalidiert im Hintergrund. Erst nach 660 Sekunden (60 + 600) blockiert er tatsächlich einen Request, um zu erneuern. Bei den meisten Inhalten — Dashboards, Blog-Feeds, Suchergebnisse — merken Nutzer das kurze Veraltungsfenster nie.

CDNs wie Cloudflare und Vercels Edge Network beachten diese Direktive. Die Browser ziehen nach: Chrome und Firefox unterstützen sie beide. Kombiniere sie immer mit must-revalidate, wenn du harte Ablaufgarantien brauchst.

CDN-Cache und Browser-Cache trennen

Eines der stärksten und am wenigsten genutzten Features von Cache-Control ist die Unterscheidung zwischen max-age und s-maxage. Sie sehen ähnlich aus, steuern aber unterschiedliche Schichten.

tstypescript
function getHeadersForResource(type: "user-data" | "public-api" | "asset") {
  switch (type) {
    case "user-data":
      // Browser caches, CDN skips
      return "private, max-age=300";
 
    case "public-api":
      // CDN holds for 5 minutes, browser revalidates every 60 seconds
      return "public, s-maxage=300, max-age=60, stale-while-revalidate=600";
 
    case "asset":
      // Both cache forever — content-addressed URLs handle invalidation
      return "public, max-age=31536000, immutable";
  }
}

Die immutable-Direktive verdient eine eigene Erwähnung. Sie sagt Caches, dass sich die Antwort nie ändern wird — bedingte Requests sind damit nie nötig. Setze sie nur bei inhaltsadressierten Ressourcen ein (Dateien mit einem Hash in der URL). Wenn du deine Assets neu baust, ändert sich die URL, sodass alte gecachte Antworten nie zum Problem werden.

Invalidierung und der Vary-Header

Cache-Invalidierung ist berüchtigt schwierig. Die pragmatische Antwort ist in den meisten Architekturen, drumherum zu entwerfen statt dagegen anzukämpfen: inhaltsadressierte URLs für Assets (das erledigt dein Bundler), kurze TTLs oder no-cache mit ETags für veränderliche Daten und surrogate-key-Tagging für CDN-Purges.

Der Vary-Header weist Caches an, je nach Request-Headern getrennte Antworten zu speichern. Das ist entscheidend, wenn du abhängig von Accept-Encoding, Accept-Language oder Authorization unterschiedliche Inhalte auslieferst.

tstypescript
// ❌ CDN may serve gzipped content to a client that sent no Accept-Encoding
res.setHeader("Cache-Control", "public, max-age=3600");
 
// ✅ Vary on encoding so CDN stores separate versions
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader("Vary", "Accept-Encoding");

Vorsicht bei Vary: * — das deaktiviert Shared Caching praktisch komplett, weil keine zwei Requests als gleichwertig gelten. Manche CDNs ignorieren den Vary-Header vollständig und normalisieren die Kodierung selbst. Sieh in der Dokumentation deines CDNs nach, bevor du dich bei etwas anderem als der Kodierung auf Vary verlässt.

!

Verwende Vary: Authorization niemals mit einem öffentlichen CDN. Beachtet das CDN den Header, erzeugt jedes einzelne Token einen eigenen Cache-Eintrag und lässt die Cache-Größe explodieren. Beachtet es ihn nicht, riskierst du, die Daten eines Nutzers an einen anderen auszuliefern.

Die wichtigsten Punkte

  1. no-cache heißt nicht „nicht cachen" — es heißt: jedes Mal revalidieren. Nimm no-store, wenn du wirklich kein Caching willst.
  2. Trenne CDN- und Browser-TTLs mit s-maxage und max-age — das sind unabhängige Stellschrauben für unabhängige Schichten.
  3. ETags zahlen sich bei großen, unvorhersehbar wechselnden Antworten aus — ein 304 ohne Body ist immer schneller als ein 200 mit vollem Payload.
  4. stale-while-revalidate hebt den Zielkonflikt auf zwischen Frische und Latenz — für nahezu alle nicht sensiblen Inhalte.
  5. Entwirf für die Invalidierung, nicht dagegen — inhaltsadressierte URLs, Surrogate Keys und kurze TTLs schlagen manuelle Purges jedes Mal.
  6. Teste das tatsächliche Verhalten deines CDNs — Direktiven werden von Cloudflare, Fastly, AWS CloudFront und Vercel unterschiedlich interpretiert. Was die Spezifikation sagt und was dein CDN tut, kann auseinandergehen.
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX