Caching ist der schnellste Weg, eine API performanter zu machen. Aber falsches Caching führt zu veralteten Daten, Inkonsistenzen und frustrierten Nutzern. Die Kunst liegt darin, das richtige Gleichgewicht zwischen Performance und Aktualität zu finden.
Dieser Artikel zeigt, wie du HTTP-Caching richtig einsetzt: Cache-Control für verschiedene Szenarien, ETags für Conditional Requests und Strategien für verschiedene Cache-Schichten.
Zielbild
Nach diesem Artikel kannst du:
- Cache-Control-Header für verschiedene Ressourcen konfigurieren
- ETags für effiziente Conditional Requests nutzen
- Verschiedene Cache-Schichten (Client, CDN, Server) orchestrieren
- Sensitive Daten vom Caching ausschließen
- Cache-Invalidierung strategisch planen
Kernfragen
Bevor du weiterliest, versuche diese Fragen für dein Projekt zu beantworten:
- Welche Daten dürfen gecacht werden? Öffentlich? Privat? Gar nicht?
- Wie lange sind Daten gültig? Sekunden? Minuten? Stunden?
- Wer darf cachen? Nur Client? Auch CDN? Shared Proxies?
- Wie invalidiert ihr Caches? TTL? Explizit? ETags?
- Wie verhindert ihr Caching sensibler Daten?
Cache-Schichten
Caching kann auf mehreren Ebenen stattfinden.
Client Cache (Browser)
- Kontrolle:
Cache-Control: private - Scope: Nur dieser User
- Use Case: User-spezifische Daten
CDN/Proxy Cache (Shared)
- Kontrolle:
Cache-Control: public - Scope: Alle User
- Use Case: Öffentliche, statische Daten
Application Cache (Server-Side)
- Kontrolle: Anwendungslogik
- Scope: Alle Requests
- Use Case: Teure Berechnungen, DB-Queries
Cache-Control Header
Der wichtigste Header für HTTP-Caching.
Syntax
Cache-Control: directive1, directive2, ...
Wichtige Direktiven
| Direktive | Bedeutung |
|---|---|
public |
Jeder Cache darf speichern |
private |
Nur Browser-Cache, keine Proxies |
no-cache |
Immer revalidieren vor Nutzung |
no-store |
Niemals speichern |
max-age=N |
N Sekunden gültig |
s-maxage=N |
N Sekunden für Shared Caches |
must-revalidate |
Nach Ablauf zwingend revalidieren |
immutable |
Ändert sich nie (für versionierte URLs) |
stale-while-revalidate=N |
Stale Content zeigen während Revalidierung |
Typische Kombinationen
Öffentliche, statische Daten:
Cache-Control: public, max-age=86400, s-maxage=604800
→ Browser: 1 Tag, CDN: 7 Tage
User-spezifische Daten:
Cache-Control: private, max-age=300
→ Nur Browser, 5 Minuten
Sensitive Daten:
Cache-Control: no-store
→ Niemals cachen
Immer frisch, aber cachen erlaubt:
Cache-Control: no-cache
→ Cache OK, aber immer erst beim Server fragen (ideal mit ETag/Last-Modified)
Versionierte Assets:
Cache-Control: public, max-age=31536000, immutable
→ 1 Jahr, URL enthält Version/Hash
Cache-Control nach Ressourcentyp
| Ressource | Cache-Control | Begründung |
|---|---|---|
| Produktliste | public, max-age=300 |
Öffentlich, 5 min OK |
| Produktdetail | public, max-age=3600 |
Ändert sich selten |
| User-Profil | private, max-age=60 |
User-spezifisch |
| Bestellungen | private, no-cache |
Aktuell, aber cachebar |
| Auth-Token | no-store |
Niemals cachen |
| API-Keys | no-store |
Niemals cachen |
| Search-Results | private, max-age=60 |
User-Query-spezifisch |
ETags und Conditional Requests
ETags ermöglichen effiziente Revalidierung: Hat sich etwas geändert?
Wie ETags funktionieren
Erster Request:
GET /v1/products/123 HTTP/1.1
HTTP/1.1 200 OK
ETag: "abc123"
Cache-Control: private, max-age=60
{"id": "123", "name": "Widget", "price": 29.99}
Nach Ablauf (Conditional Request):
GET /v1/products/123 HTTP/1.1
If-None-Match: "abc123"
HTTP/1.1 304 Not Modified
ETag: "abc123"
→ Kein Body, Client nutzt gecachte Version.
Wenn geändert:
GET /v1/products/123 HTTP/1.1
If-None-Match: "abc123"
HTTP/1.1 200 OK
ETag: "def456"
{"id": "123", "name": "Widget Pro", "price": 39.99}
ETag-Generierung
| Methode | Beispiel | Use Case |
|---|---|---|
| Content Hash | sha256(response_body) |
Exakt, aber CPU-intensiv |
| Version/Timestamp | "v3", "1706522400" |
Einfach, DB-basiert |
| Composite | {version}-{updated_at} |
Kombination |
Implementierung:
import hashlib
import json
def generate_etag(resource: dict) -> str:
# Option 1: Content Hash
content = json.dumps(resource, sort_keys=True)
return f'"{hashlib.sha256(content.encode()).hexdigest()}"'
# Option 2: Version-based
return f'"{resource["version"]}"'
# Option 3: Timestamp
return f'"{int(resource["updated_at"].timestamp())}"'
Hinweis: ETags sind opaque und kein Security-Mechanismus. Leite keine sensiblen Informationen daraus ab und verlasse dich nicht auf Hashes für Sicherheit.
Weak vs. Strong ETags
| Typ | Syntax | Bedeutung |
|---|---|---|
| Strong | "abc123" |
Byte-für-Byte identisch |
| Weak | W/"abc123" |
Semantisch äquivalent |
Weak ETags wenn:
- Komprimierung (gzip) den Body ändert
- Formatierung variiert (Pretty Print vs. Minified)
- Metadaten sich ändern, aber nicht der Inhalt
ETag: W/"abc123"
Conditional Request für Updates
ETags funktionieren auch für Updates (Optimistic Locking):
PUT /v1/products/123 HTTP/1.1
If-Match: "abc123"
Content-Type: application/json
{"name": "Widget Pro", "price": 39.99}
Wenn ETag nicht passt:
HTTP/1.1 412 Precondition Failed
ETag: "def456"
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/precondition-failed",
"title": "Precondition Failed",
"status": 412,
"detail": "Resource was modified. Please reload and retry.",
"error_code": "RESOURCE_CONFLICT",
"request_id": "req_def456"
}
Wenn If-Match erforderlich ist, aber fehlt: 428 Precondition Required.
Für If-Match nur starke ETags verwenden (kein W/).
Vary Header
Der Vary Header sagt Caches, welche Request-Header die Response beeinflussen.
Problem ohne Vary
Lösung mit Vary
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Vary: Accept-Language
{"greeting": "Hello"}
Jetzt cached der Proxy pro Sprache separat.
Typische Vary-Verwendung
| Vary | Use Case |
|---|---|
Accept-Language |
Mehrsprachige Responses |
Accept-Encoding |
gzip/br Komprimierung |
Authorization |
User-spezifische Daten |
Accept |
Content Negotiation (JSON/XML) |
Origin |
CORS |
Warnung: Vary: * oder zu viele Vary-Header reduzieren Cache-Effizienz.
Vary und CDN
CDNs cachen oft nicht bei Vary: Authorization. Lösung:
# Statt Vary: Authorization
Cache-Control: private, max-age=300
Oder CDN-spezifische Header (falls unterstützt):
Surrogate-Control: max-age=3600
Cache-Control: private, max-age=0
Caching-Strategien
Verschiedene Ressourcen brauchen verschiedene Strategien.
Immutable Resources
Ressourcen, die sich nie ändern (Version in URL).
GET /v1/assets/logo-v3.png HTTP/1.1
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
Use Case: Assets mit Hash/Version in URL, API-Versionen
Time-Based Caching
Daten sind X Zeit gültig.
Cache-Control: public, max-age=300
Use Case: Produktkataloge, öffentliche Listen
Validation-Based Caching
Immer Server fragen, aber 304 wenn unverändert.
Cache-Control: no-cache
ETag: "abc123"
Use Case: Daten, die aktuell sein müssen, aber oft unverändert sind
Stale-While-Revalidate
Zeige gecachte Version, revalidiere im Hintergrund.
Cache-Control: max-age=60, stale-while-revalidate=300
→ 60s frisch, dann bis zu 300s stale zeigen während Revalidierung.
Use Case: Hohe Verfügbarkeit wichtiger als absolute Aktualität
No Caching
Sensitive oder hochdynamische Daten.
Cache-Control: no-store
Use Case: Auth-Tokens, persönliche Daten, Finanzdaten
Cache-Invalidierung
There are only two hard things in Computer Science: cache invalidation and naming things.
– Phil Karlton
Strategien
| Strategie | Mechanismus | Use Case |
|---|---|---|
| TTL-based | max-age läuft ab |
Einfach, eventual consistent |
| Event-based | Purge bei Update | Sofortige Invalidierung |
| Version-based | URL ändert sich | Immutable Resources |
| Tag-based | Purge by Tag | Gruppenweise Invalidierung |
TTL-based (Passiv)
Cache-Control: max-age=300
Nach 5 Minuten automatisch ungültig. Einfach, aber verzögert.
Event-based (Aktiv)
Bei Update: Cache explizit löschen.
async def update_product(product_id: str, data: dict):
product = await Product.update(product_id, data)
# Cache invalidieren
await cdn.purge(f"/v1/products/{product_id}")
await redis.delete(f"product:{product_id}")
return product
Version-based
URL enthält Version, alte URLs werden nie mehr aufgerufen.
/v1/assets/styles-abc123.css # alte Version
/v1/assets/styles-def456.css # neue Version
Mit immutable: Browser fragt nie wieder nach alter URL.
Tag-based Purging
CDNs wie Fastly/Cloudflare unterstützen Cache-Tags:
HTTP/1.1 200 OK
Cache-Tag: product, product-123, category-electronics
Bei Update:
await cdn.purge_by_tag("product-123")
# Oder alle Produkte:
await cdn.purge_by_tag("product")
Sensitive Daten und Caching
Manche Daten dürfen nie gecacht werden.
Niemals cachen
| Datentyp | Beispiel |
|---|---|
| Auth-Tokens | JWT, API Keys |
| Passwörter | Auch gehashte |
| Session-Daten | CSRF Tokens |
| Zahlungsdaten | Kreditkartennummern |
| PII | Sozialversicherungsnummer |
| Medizinische Daten | Diagnosen, Rezepte |
Cache-Control: no-store
Pragma: no-cache
Pragma: no-cache ist ein Legacy-Header für HTTP/1.0 und optional.
Mit Vorsicht cachen
| Datentyp | Cache-Control |
|---|---|
| User-Profil | private, max-age=60 |
| Bestellhistorie | private, no-cache |
| Nachrichten | private, max-age=30 |
HTTPS-Only
Caches sollten nur über HTTPS gefüllt werden:
Strict-Transport-Security: max-age=31536000
Server-Side Caching
Nicht nur HTTP-Caching – auch serverseitig kann gecacht werden.
Application Cache (Redis)
async def get_product(product_id: str) -> dict:
# 1. Cache prüfen
cached = await redis.get(f"product:{product_id}")
if cached:
return json.loads(cached)
# 2. Aus DB laden
product = await db.get_product(product_id)
# 3. In Cache speichern
await redis.setex(
f"product:{product_id}",
300, # 5 Minuten TTL
json.dumps(product)
)
return product
Query Cache
import hashlib
import json
async def search_products(query: str, filters: dict) -> list:
key_payload = json.dumps({"q": query, "filters": filters}, sort_keys=True)
cache_key = f"search:{hashlib.sha256(key_payload.encode()).hexdigest()}"
cached = await redis.get(cache_key)
if cached:
return json.loads(cached)
results = await db.search(query, filters)
await redis.setex(cache_key, 60, json.dumps(results))
return results
Cache-Aside vs. Read-Through
Cache-Aside (Application managed):
# Application entscheidet, wann Cache gefüllt wird
result = cache.get(key)
if result is None:
result = db.get(key)
cache.set(key, result)
Read-Through (Cache managed):
# Cache lädt automatisch aus DB (Cache-Loader)
result = cache.get_or_load(key, loader=db.get)
Regeln & Anti-Patterns
Do
Cache-Controlfür jede Response setzenno-storefür sensitive Daten- ETags für Conditional Requests
Varybei Content Negotiationprivatefür user-spezifische Datenimmutablefür versionierte URLs- Kurze TTLs +
stale-while-revalidatefür Verfügbarkeit
Don't
- Sensitive Daten mit
publiccachen Vary: *(deaktiviert Caching)- Zu lange TTLs ohne Invalidierungs-Strategie
- ETags ohne
Cache-Control(Browser-abhängig) - Authorization-Header ohne
privateoderno-store - Cache-Invalidierung vergessen bei Updates
Artefakt: Caching-Policy
# Caching-Policy
## Grundregeln
1. Jede Response hat einen `Cache-Control` Header
2. Sensitive Daten: `no-store`
3. User-spezifische Daten: `private`
4. Öffentliche Daten: `public` mit angemessener TTL
5. ETags für alle GET-Responses auf Ressourcen
## Cache-Control nach Endpoint
| Endpoint | Cache-Control | ETag | Begründung |
|-------------------------|--------------------------------------------------|------|--------------------------|
| `GET /v1/products` | `public, max-age=300, stale-while-revalidate=60` | Ja | Liste, 5 min frisch |
| `GET /v1/products/{id}` | `public, max-age=3600` | Ja | Detail, 1 h frisch |
| `GET /v1/categories` | `public, max-age=86400` | Ja | Selten Änderungen |
| `GET /v1/users/me` | `private, max-age=60` | Ja | User-spezifisch |
| `GET /v1/orders` | `private, no-cache` | Ja | Aktuell, user-spezifisch |
| `GET /v1/orders/{id}` | `private, no-cache` | Ja | Aktuell, user-spezifisch |
| `POST /v1/auth/token` | `no-store` | Nein | Sensitive |
| `GET /v1/config` | `private, max-age=300` | Ja | User-Config |
## ETag-Generierung
Format: `"{version}-{updated_at_timestamp}"`
Beispiel: `"3-1706522400"`
## Vary Header
| Endpoint | Vary |
|-----------------|-------------------------------------------------|
| Alle | `Accept-Encoding` |
| Mehrsprachig | `Accept-Encoding, Accept-Language` |
| User-spezifisch | – (nutze `private` statt `Vary: Authorization`) |
## Cache-Invalidierung
### Automatisch (TTL)
- Produktliste: 5 Minuten
- Produktdetail: 1 Stunde
- Kategorien: 24 Stunden
### Manuell (Events)
Bei folgenden Events wird der Cache invalidiert:
| Event | Invalidierung |
|------------------|---------------------------------------------------|
| Produkt Update | `purge(/v1/products/{id})`, `purge_tag(products)` |
| Kategorie Update | `purge_tag(categories)` |
| Preis-Änderung | `purge_tag(products)` |
### CDN-Konfiguration
```text
CDN: Cloudflare/Fastly
Purge: API-gesteuert bei Updates
Cache-Tags: product, category, user-{id}
```
## Server-Side Caching (Redis)
| Key-Pattern | TTL | Invalidierung |
|------------------------|-------|--------------------------|
| `product:{id}` | 5 min | Bei Update |
| `products:list:{hash}` | 1 min | Bei jedem Produkt-Update |
| `search:{hash}` | 1 min | TTL only |
| `user:{id}:profile` | 5 min | Bei Update |
## Conditional Requests
### GET mit If-None-Match
```http
GET /v1/products/123 HTTP/1.1
If-None-Match: "3-1706522400"
# Unverändert:
HTTP/1.1 304 Not Modified
# Geändert:
HTTP/1.1 200 OK
ETag: "4-1706530000"
```
### PUT/PATCH mit If-Match
```http
PUT /v1/products/123 HTTP/1.1
If-Match: "3-1706522400"
# Erfolg:
HTTP/1.1 200 OK
ETag: "4-1706530000"
# Konflikt:
HTTP/1.1 412 Precondition Failed
```
## Beispiel-Responses
### Öffentliche Ressource
```http
HTTP/1.1 200 OK
Cache-Control: public, max-age=300, stale-while-revalidate=60
ETag: "abc123"
Vary: Accept-Encoding
Content-Type: application/json
{"products": []}
```
### Private Ressource
```http
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "user-v5"
Content-Type: application/json
{"id": "user_123", "name": "Alice"}
```
### Sensitive Ressource
```http
HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json
{"access_token": "eyJ_REDACTED", "expires_in": 3600}
```
### 304 Not Modified
```http
HTTP/1.1 304 Not Modified
ETag: "abc123"
Cache-Control: public, max-age=300
```
Checkliste
Bevor du zum nächsten Artikel gehst, prüfe:
- [ ]
Cache-Controlist für alle Endpoints definiert - [ ] Sensitive Daten haben
no-store - [ ] User-spezifische Daten haben
private - [ ] Öffentliche Daten haben angemessene
max-age - [ ] ETags werden für GET-Ressourcen generiert
- [ ] Conditional Requests (If-None-Match) werden unterstützt
- [ ]
VaryHeader bei Content Negotiation - [ ] Cache-Invalidierungs-Strategie existiert
- [ ] Server-Side Caching ist implementiert (wenn nötig)
- [ ] Caching-Policy ist dokumentiert
Wie es weitergeht
Im nächsten Teil geht es um Observability: Structured Logging, Metrics, Distributed Tracing und wie du deine API debuggbar und monitorbar machst.
Alle Teile der Serie: Serie: API-Design