TS
Thomas Schmitz

Freiberuflicher IT-Berater, Softwareentwickler & DevOps. Praxisnahe Artikel zu API-Design, Architektur und Cloud-Workflows.

Serie: API-Design · Teil 15 von 22

API-Design Teil 15: Caching

Cache-Control, ETags, Conditional Requests – wie du Caching nutzt, ohne Konsistenz-Probleme zu verursachen.

Praxisnah Checkliste

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:

  1. Welche Daten dürfen gecacht werden? Öffentlich? Privat? Gar nicht?
  2. Wie lange sind Daten gültig? Sekunden? Minuten? Stunden?
  3. Wer darf cachen? Nur Client? Auch CDN? Shared Proxies?
  4. Wie invalidiert ihr Caches? TTL? Explizit? ETags?
  5. Wie verhindert ihr Caching sensibler Daten?

Cache-Schichten

Caching kann auf mehreren Ebenen stattfinden.

Browser
Cache

CDN
Cache

Gateway
Cache

API
Server

Private

Shared

Shared

Origin

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

APICacheClientAPICacheClientRequest 1 (Accept-Language: en)Cache MISSResponse in EnglishResponse in English (cached)Request 2 (Accept-Language: de)Response in English (falsch)

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-Control für jede Response setzen
  • no-store für sensitive Daten
  • ETags für Conditional Requests
  • Vary bei Content Negotiation
  • private für user-spezifische Daten
  • immutable für versionierte URLs
  • Kurze TTLs + stale-while-revalidate für Verfügbarkeit

Don't

  • Sensitive Daten mit public cachen
  • Vary: * (deaktiviert Caching)
  • Zu lange TTLs ohne Invalidierungs-Strategie
  • ETags ohne Cache-Control (Browser-abhängig)
  • Authorization-Header ohne private oder no-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-Control ist 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
  • [ ] Vary Header 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

Mehr Beiträge aus dem Blog.