HTTP-Caching-Mechanismen für skalierbare APIs
Eine REST-API, die bei jedem Request dieselben Daten aus der Datenbank lädt und vollständig zurücksendet, verschwendet Bandbreite und Serverressourcen. ETag, Last-Modified und Cache-Control sind die HTTP-Standard-Werkzeuge, die genau das verhindern – mit 304 Not Modified für unveränderte Ressourcen und Proxy-Caching für öffentliche Daten.
Inhaltsverzeichnis
- 1. HTTP-Caching-Grundlagen für REST-APIs
- 2. Cache-Control: Direktiven und ihre Bedeutung
- 3. ETag: Starke und schwache Validatoren
- 4. Last-Modified und Conditional Requests
- 5. Der 304-Not-Modified-Flow im Detail
- 6. Caching in Symfony: Response-Klasse und HttpCache
- 7. Vary-Header: Cache-Segmentierung nach Request-Eigenschaften
- 8. Caching-Strategie-Vergleich: Welches Pattern für welchen Fall
- 9. Zusammenfassung
- 10. FAQ
1. HTTP-Caching-Grundlagen für REST-APIs
HTTP-Caching ist kein optionales Feature, sondern ein fundamentaler Bestandteil des HTTP-Protokolls. Der RFC 9111 definiert präzise, wie Clients, Proxies und Server mit Cache-Daten umgehen sollen. Für REST-APIs sind drei Caching-Mechanismen relevant: Freshness-Caching (Cache-Control mit max-age), bei dem der Client einen gespeicherten Response für eine definierte Zeit nutzt, ohne den Server zu kontaktieren; Validation-Caching (ETag und Last-Modified), bei dem der Client fragt, ob sich die Ressource geändert hat; und Proxy-Caching, bei dem ein Reverse Proxy (Varnish, Nginx) Responses für alle Clients speichert.
Der entscheidende Unterschied zwischen diesen drei Mechanismen liegt darin, welcher Teil der Verarbeitungskette entlastet wird. Freshness-Caching vermeidet Requests komplett – gut für statische oder selten wechselnde Daten. Validation-Caching reduziert die übertragene Datenmenge, aber der Server muss noch einen Conditional Request bearbeiten – gut für häufig abgefragte, aber selten ändernde Ressourcen. Proxy-Caching entlastet den Applikationsserver komplett für alle öffentlichen Ressourcen. In einer typischen REST-API kommen alle drei Mechanismen parallel zum Einsatz, auf verschiedene Endpoints angewandt.
2. Cache-Control: Direktiven und ihre Bedeutung
Der Cache-Control-Header ist der wichtigste Cache-Header in HTTP/1.1 und HTTP/2. Er steuert, welche Caches eine Ressource speichern dürfen und wie lange. Die wichtigsten Direktiven für REST-APIs: no-store verhindert jede Speicherung – für sensitive Daten wie Authentifizierungsantworten und persönliche Nutzerdaten; no-cache erlaubt Speicherung, aber der Client muss vor jeder Verwendung validieren; private erlaubt nur Client-seitiges Caching, kein Proxy-Caching – für benutzerspezifische Daten; public erlaubt Proxy-Caching; max-age=N definiert die Lebensdauer in Sekunden.
Die Kombination der Direktiven bestimmt das genaue Verhalten. Cache-Control: public, max-age=300 erlaubt Proxy-Caches, die Ressource 5 Minuten lang zu speichern und für alle Clients zurückzugeben. Cache-Control: private, max-age=60, must-revalidate erlaubt dem Browser, die Ressource 60 Sekunden lang zu nutzen, danach muss er validieren. Cache-Control: no-cache, no-store verhindert jedes Caching komplett – typisch für Authentifizierungs-Endpoints und aktuelle Währungskurse. Die Wahl der richtigen Direktiven für jeden Endpoint ist eine fachliche, nicht nur technische Entscheidung.
<?php
// src/Controller/ProductController.php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ProductRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
final class ProductController
{
public function __construct(
private readonly ProductRepository $products,
) {}
#[Route('/products/{id}', methods: ['GET'])]
public function show(int $id, Request $request): Response
{
$product = $this->products->find($id);
if ($product === null) {
return new JsonResponse(['code' => 'PRODUCT_NOT_FOUND'], 404);
}
// Generate ETag from content hash (strong validator)
$data = [
'id' => $product->getId(),
'name' => $product->getName(),
'price' => $product->getPrice(),
'updatedAt' => $product->getUpdatedAt()->format(\DateTimeInterface::RFC3339),
];
$etag = '"' . md5(json_encode($data, JSON_THROW_ON_ERROR)) . '"';
$lastModified = $product->getUpdatedAt();
$response = new JsonResponse($data);
$response->setEtag($etag);
$response->setLastModified($lastModified);
$response->setPublic();
$response->setMaxAge(300); // 5 min fresh for proxies
$response->setSharedMaxAge(300); // s-maxage=300 for CDN/Varnish
// Check if client already has a valid copy (304 if not modified)
if ($response->isNotModified($request)) {
return $response; // Symfony sends 304 automatically
}
return $response;
}
#[Route('/products', methods: ['GET'])]
public function list(Request $request): Response
{
$products = $this->products->findAll();
$data = array_map(fn($p) => ['id' => $p->getId(), 'name' => $p->getName()], $products);
// Use collection-level ETag (hash of all product IDs + updatedAt)
$hashInput = implode(',', array_map(
fn($p) => $p->getId() . ':' . $p->getUpdatedAt()->getTimestamp(),
$products
));
$etag = '"' . md5($hashInput) . '"';
$response = new JsonResponse($data);
$response->setEtag($etag);
$response->setPublic();
$response->setSharedMaxAge(60); // Collections expire faster
if ($response->isNotModified($request)) {
return $response;
}
return $response;
}
}
3. ETag: Starke und schwache Validatoren
Ein ETag (Entity Tag) ist ein eindeutiger Identifier für eine bestimmte Version einer Ressource. Starke ETags (in doppelten Anführungszeichen: "abc123") garantieren, dass sich der Ressourcen-Inhalt byte-genau nicht verändert hat. Schwache ETags (mit W/-Präfix: W/"abc123") signalisieren, dass die Ressource semantisch äquivalent ist, aber nicht zwingend byte-identisch – nützlich, wenn eine komprimierte und unkomprimierte Version als äquivalent gelten sollen.
ETags werden aus dem Response-Inhalt generiert, typischerweise als MD5- oder SHA256-Hash des serialisierten JSON-Bodies oder direkt aus einem version-Feld der Entität. Bei Datenbankentitäten mit einem updatedAt-Timestamp ist ein Hash aus id + updatedAt oft ausreichend und vermeidet, den vollständigen Datensatz serialisieren zu müssen. ETags sind zuverlässiger als Last-Modified, weil sie nicht von der Serverzeit abhängen und Änderungen innerhalb derselben Sekunde erkennen – wichtig bei schnellen Writes in einer hochfrequenten API.
4. Last-Modified und Conditional Requests
Der Last-Modified-Header gibt den Zeitstempel der letzten Änderung einer Ressource als HTTP-Date an. Clients speichern diesen Wert und senden ihn bei nachfolgenden Requests im If-Modified-Since-Header zurück. Der Server vergleicht den gesendeten Zeitstempel mit dem tatsächlichen Änderungszeitpunkt der Ressource: Hat sie sich nicht verändert, antwortet er mit 304 Not Modified und keinem Body. Hat sie sich verändert, sendet er die aktuellen Daten mit dem neuen Last-Modified-Zeitstempel.
Der Vorteil von Last-Modified gegenüber ETag liegt in der einfacheren Implementierung: Ein updatedAt-Timestamp ist in den meisten Datenbankentitäten ohnehin vorhanden. Der Nachteil: Last-Modified hat eine Granularität von einer Sekunde. Mehrere Änderungen innerhalb derselben Sekunde sind nicht unterscheidbar. In modernen APIs, wo Writes in Millisekunden-Abständen auftreten können, ist ETag deshalb die bevorzugte Methode. In der Praxis empfiehlt sich die Kombination beider Header: ETag als primären Validator und Last-Modified als Fallback für ältere Clients.
5. Der 304-Not-Modified-Flow im Detail
Der 304-Not-Modified-Flow ist das Herzstück des Validation-Cachings. Der Ablauf: Client sendet GET /products/42 mit If-None-Match: "abc123" (dem gespeicherten ETag) und optional If-Modified-Since: Thu, 15 Jan 2025 10:30:00 GMT. Der Server lädt die Ressource, berechnet den aktuellen ETag und vergleicht: Stimmt er überein, sendet der Server 304 Not Modified ohne Body, aber mit denselben Caching-Headern (Cache-Control, ETag, Last-Modified). Der Client verwendet seinen gespeicherten Response-Body und aktualisiert die Cache-Metadaten.
Das Ergebnis: Der Netzwerk-Overhead reduziert sich auf die Größe der Header – typischerweise ein paar Hundert Bytes statt mehrerer Kilobyte Body. Die Serverlast reduziert sich auf den Datenbankzugriff zum Lesen des updatedAt-Timestamps – kein Serialisieren, kein JSON-Encoding, keine Middleware-Verarbeitung des vollen Response-Bodies. Bei APIs mit großen Response-Bodies (Produktlisten, Suchergebnisse) ist der Bandbreitenvorteil erheblich. In Symfony übernimmt $response->isNotModified($request) den Vergleich und das Setzen des 304-Status automatisch.
<?php
// src/EventSubscriber/CacheHeaderSubscriber.php
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Adds standard security headers and ensures cache headers are correct
* for public vs. authenticated endpoints.
*/
final class CacheHeaderSubscriber implements EventSubscriberInterface
{
private const PUBLIC_PATHS = ['/products', '/categories', '/search'];
private const NO_CACHE_PATHS = ['/auth/', '/users/me', '/orders'];
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => 'onResponse'];
}
public function onResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$response = $event->getResponse();
$path = $request->getPathInfo();
// Never cache write operations
if (!in_array($request->getMethod(), ['GET', 'HEAD'], true)) {
$response->headers->set('Cache-Control', 'no-store');
return;
}
// Private user data: no proxy caching
foreach (self::NO_CACHE_PATHS as $prefix) {
if (str_starts_with($path, $prefix)) {
$response->setPrivate();
$response->headers->addCacheControlDirective('no-cache');
return;
}
}
// Public data: allow proxy caching
foreach (self::PUBLIC_PATHS as $prefix) {
if (str_starts_with($path, $prefix)) {
if (!$response->headers->hasCacheControlDirective('max-age')) {
$response->setPublic();
$response->setSharedMaxAge(300);
}
return;
}
}
}
}
6. Caching in Symfony: Response-Klasse und HttpCache
Symfony bietet eine vollständige HTTP-Caching-Implementierung über die Response-Klasse und den HttpCache-Kernel. Die Response-Klasse abstrahiert alle Cache-Header: setEtag(), setLastModified(), setPublic(), setPrivate(), setMaxAge(), setSharedMaxAge() und isNotModified(). Der HttpCache-Kernel ist ein vollständiger Reverse-Proxy in PHP, der im Entwicklungsumfeld und in einfachen Produktionskonfigurationen ohne externen Proxy eingesetzt werden kann.
Für Produktionsumgebungen mit hohem Traffic ist ein externer Reverse-Proxy (Varnish, Nginx) leistungsfähiger als der PHP-basierte HttpCache. Symfony HTTP Cache unterstützt Cache-Invalidierung über das Purge-Protokoll: PURGE /products/42 löscht den Cache-Eintrag sofort. Für komplexere Cache-Invalidierungsstrategien (Cache-Tags, Event-basierte Invalidierung) bietet FOS HttpCache eine umfassende Lösung, die mit Varnish, Nginx und Symfony HTTP Cache zusammenarbeitet. Die korrekte Cache-Invalidierung beim Schreiben ist oft komplexer als das Setzen der Cache-Header beim Lesen.
7. Vary-Header: Cache-Segmentierung nach Request-Eigenschaften
Der Vary-Header teilt Caches mit, dass die Response je nach bestimmten Request-Headern variiert. Vary: Accept-Encoding bedeutet: Eine komprimierte und eine unkomprimierte Version desselben Endpoints werden separat gecacht. Vary: Accept-Language bedeutet: Für jede Sprachversion wird ein eigener Cache-Eintrag angelegt. In REST-APIs mit Content Negotiation (Accept: application/json vs. Accept: application/ld+json) verhindert Vary: Accept, dass ein JSON-LD-Response an einen Client ausgeliefert wird, der reines JSON erwartet.
Der Vary-Header hat eine kritische Nebenwirkung: Jede zusätzliche Dimension im Vary-Header multipliziert die Anzahl der gespeicherten Cache-Einträge. Vary: Authorization ist besonders problematisch: Für jeden Nutzer würde ein eigener Cache-Eintrag angelegt – bei Tausenden von Nutzern ein erheblicher Speicheraufwand. Die Faustregel: Für benutzerspezifische Daten kein Proxy-Caching verwenden (Cache-Control: private statt Vary: Authorization). Vary nur für Dimensionen nutzen, die tatsächlich verschiedene Response-Bodies erzeugen.
8. Caching-Strategie-Vergleich: Welches Pattern für welchen Fall
Die richtige Caching-Strategie hängt von drei Faktoren ab: wie oft sich die Ressource ändert, ob die Daten benutzerspezifisch sind und wie hoch der Aufwand ist, eine gecachte Ressource zu invalidieren. Die Tabelle zeigt die empfohlene Strategie für typische REST-API-Endpoints.
| Endpoint-Typ | Cache-Control | ETag/Last-Modified | Warum |
|---|---|---|---|
| Statische Stammdaten | public, max-age=3600 | ETag | Selten geändert, für alle gleich |
| Produktdetails | public, s-maxage=300 | ETag + Last-Modified | Gelegentlich geändert, öffentlich |
| Nutzerprofil | private, max-age=60 | ETag | Benutzerspezifisch, kein Proxy |
| Auth-Token-Antwort | no-store | Keiner | Sensitiv, nie cachen |
| Suchergebnisse | private, no-cache | ETag (optional) | Parameter-abhängig, kurze Freshness |
Cache-Control-Direktiven sind keine Empfehlungen, sondern Vertragsbedingungen: Ein wohlverhaltener Cache muss no-store respektieren. Reverse Proxies wie Varnish erlauben es, Cache-Control: private für bestimmte Pfade zu ignorieren und trotzdem zu cachen – aber das ist eine explizite Fehlkonfiguration, keine Standardverhalten. Wer auf korrekte Cache-Control-Semantik setzt, kann sicher sein, dass konforme Caches das Verhalten respektieren.
Mironsoft
REST-API-Performance, HTTP-Caching und Symfony-Optimierung
HTTP-Caching für eure REST-API implementieren?
Wir analysieren eurer API-Traffic, implementieren ETag, Last-Modified und Cache-Control für die richtigen Endpoints und richten Reverse-Proxy-Caching für maximale Entlastung eures Applikationsservers ein.
Caching-Audit
Analyse der aktuellen Cache-Header und Identifikation fehlender Caching-Potenziale
ETag-Implementierung
ETag und Last-Modified für alle cachbaren Endpoints in Symfony implementieren
Proxy-Setup
Varnish oder Nginx als Reverse Proxy mit Cache-Invalidierung konfigurieren
9. Zusammenfassung
HTTP-Caching in REST-APIs ist die effektivste Maßnahme zur Reduzierung von Bandbreite und Serverlast ohne Architekturänderungen. Cache-Control steuert, ob und wie lange Responses gecacht werden. ETag und Last-Modified ermöglichen Conditional Requests und 304-Not-Modified-Antworten für unveränderte Ressourcen. Der Vary-Header segmentiert Caches nach Request-Eigenschaften. Die Kombination dieser Mechanismen reduziert in typischen REST-APIs die übertragene Datenmenge um 30–70 % bei frequenten Reads auf selten ändernde Ressourcen.
Symfony macht die Implementierung mit der Response-Klasse, isNotModified() und dem optionalen HttpCache-Kernel zugänglich. Die wichtigste Design-Entscheidung ist nicht technisch, sondern fachlich: Welcher Endpoint darf gecacht werden, von wem (private vs. public) und wie lange? Diese Entscheidung muss für jeden Endpoint explizit getroffen werden – implizite Defaults führen zu sicherheitskritischen oder veralteten Responses.
HTTP-Caching in REST-APIs — Das Wichtigste auf einen Blick
Cache-Control
public/private, max-age, s-maxage, no-store, no-cache. Für jeden Endpoint explizit setzen. Sensitive Daten: no-store. Öffentlich: public + s-maxage.
ETag
Hash aus Response-Inhalt oder version-Feld. Zuverlässiger als Last-Modified – erkennt Änderungen innerhalb derselben Sekunde. In Symfony: setEtag().
304 Not Modified
isNotModified($request) in Symfony prüft If-None-Match und If-Modified-Since automatisch. Spart Body-Übertragung, aber Datenbankzugriff bleibt.
Vary
Nur für Dimensionen die tatsächlich verschiedene Bodies erzeugen (Accept, Accept-Encoding). Vary: Authorization vermeiden – lieber Cache-Control: private.