HTTP-Caching-Header: Mehr als Cache-Control aus dem Tutorial

Julian | Jul 10, 2026 min read

Im ersten Teil unserer Serie haben wir uns mit QUERY die erste neue HTTP-Methode seit 15 Jahren angeschaut. Heute geht’s um etwas, das du wahrscheinlich täglich benutzt, ohne wirklich zu verstehen, was du damit tust: Caching-Header.

Kennst du das – du googelst “spring boot http caching”, findest einen Stack-Overflow-Post oder ein Tutorial, kopierst Cache-Control: no-cache in deinen Response-Header und denkst, du hättest Caching jetzt abgeschaltet? Das Gegenteil ist der Fall. Der Browser cacht die Antwort trotzdem – er fragt nur bei jedem Request erst höflich nach, ob sie noch aktuell ist.

Genau solche Missverständnisse sind der Grund, warum Caching-Header zu den am meisten falsch verwendeten Teilen von HTTP gehören. Nicht weil die Spec kompliziert ist, sondern weil die Begriffe intuitiv etwas anderes suggerieren, als sie tatsächlich bedeuten. Das Ergebnis: APIs, die ungewollt jeden Request neu berechnen, obwohl sie cachen könnten – oder schlimmer, APIs, die veraltete Daten ausliefern, weil ein Cache irgendwo in der Kette Dinge länger vorhält als gedacht.

In diesem Artikel schauen wir uns an, wie HTTP-Caching wirklich funktioniert: den Unterschied zwischen Freshness und Validation, wie du ETags und Conditional Requests in Spring Boot einsetzt, und worauf du achten musst, sobald Proxies oder CDNs zwischen deinem Client und deinem Server sitzen.

Freshness vs. Validation – die zwei Caching-Strategien

HTTP kennt im Kern zwei Antworten auf die Frage “darf ein Cache diese Antwort wiederverwenden, ohne beim Server nachzufragen?” – und die meisten Verwirrung entsteht, weil beide Antworten über denselben Header laufen: Cache-Control.

Freshness heißt: Der Cache darf die Antwort für eine bestimmte Zeit einfach wiederverwenden, ganz ohne Server-Kontakt. Das steuerst du mit max-age:

Cache-Control: max-age=300, public

Fünf Minuten lang bekommt der Client die Antwort direkt aus dem Cache – der Server sieht in dieser Zeit gar keinen Request.

Validation heißt dagegen: Der Cache darf die Antwort behalten, muss aber bei jeder Nutzung erst beim Server nachfragen, ob sie noch aktuell ist. Genau das ist no-cache – und hier liegt die Falle aus der Einleitung:

// Anti-Pattern: "no-cache" wird hier missverstanden als "nicht cachen"
@GetMapping("/products/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
    Product product = productService.findById(id);
    return ResponseEntity.ok()
            .header(HttpHeaders.CACHE_CONTROL, "no-cache")
            .body(product);
}

Der Name suggeriert “kein Caching”. Tatsächlich passiert: Der Browser speichert die Antwort trotzdem, schickt bei jedem weiteren Request aber einen Conditional Request (dazu gleich mehr) und bekommt im Idealfall nur ein schlankes 304 zurück statt der vollen Antwort. Das ist nicht falsch – aber wenn du eigentlich gar kein Caching willst, zum Beispiel bei sensiblen Daten, brauchst du eine andere Direktive:

// Korrekt: Antwort wird nirgendwo gespeichert, auch nicht kurzzeitig
@GetMapping("/users/{id}/payment-methods")
public ResponseEntity<PaymentMethods> getPaymentMethods(@PathVariable Long id) {
    PaymentMethods methods = paymentService.findByUserId(id);
    return ResponseEntity.ok()
            .cacheControl(CacheControl.noStore())
            .body(methods);
}

no-store ist die einzige Direktive, die tatsächlich “nicht speichern, unter keinen Umständen” bedeutet.

Für Daten, die sich selten ändern und keinen exakten Echtzeit-Stand brauchen, ist Freshness-basiertes Caching mit max-age die bessere Wahl, weil sie dem Server komplett erspart, überhaupt gefragt zu werden:

@GetMapping("/products/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
    Product product = productService.findById(id);
    return ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
            .body(product);
}

Die kurze Merkregel für deinen Stack: max-age spart den Request komplett, no-cache spart nur den Payload (dank Validation), no-store spart gar nichts – und genau das ist bei sensiblen Daten der Punkt.

DirektiveBedeutung
max-age=NCache darf N Sekunden ohne Rückfrage wiederverwenden
no-cacheCache muss vor jeder Nutzung revalidieren (nicht: “nicht cachen”)
no-storeNichts speichern, weder Client- noch Shared-Cache
privateNur Browser-Cache, nicht in geteilten Caches (Proxy, CDN)
publicAuch in geteilten Caches erlaubt

Damit no-cache überhaupt sinnvoll funktioniert, braucht der Server einen Weg, “noch aktuell” schnell und günstig zu beantworten – das ist die Rolle von ETags und Conditional Requests, die wir uns als Nächstes anschauen.

Validation in der Praxis: ETag, Last-Modified & Conditional Requests

Ein ETag ist im Kern nichts anderes als ein Fingerabdruck einer bestimmten Version deiner Ressource – ein String, den Client und Server vergleichen können, ohne die komplette Antwort neu zu übertragen. Der Ablauf:

  1. Server liefert eine Antwort mit ETag: "42" aus.
  2. Beim nächsten Request schickt der Client If-None-Match: "42" mit.
  3. Server vergleicht: Passt das ETag noch? Dann antwortet er mit 304 Not Modified – ganz ohne Body. Passt es nicht mehr, gibt’s die volle Antwort mit neuem ETag.

So spart sich der Client die Übertragung, obwohl der Server bei jedem Request weiterhin gefragt wird – das ist genau das Verhalten, das no-cache aus dem letzten Abschnitt erst sinnvoll macht.

In Spring Boot übernimmt WebRequest.checkNotModified() den kompletten Vergleich für dich:

@GetMapping("/products/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id, WebRequest request) {
    ProductMeta meta = productService.findMeta(id); // günstig: nur id, version, updatedAt
    String etag = "\"" + meta.version() + "\"";

    if (request.checkNotModified(etag)) {
        return null; // Spring setzt automatisch 304, kein Body wird geschrieben
    }

    Product product = productService.findById(id); // teure Operation nur wenn nötig
    return ResponseEntity.ok()
            .eTag(etag)
            .body(product);
}

Der entscheidende Punkt hier: findMeta() liest nur ein Versionsfeld aus der Datenbank, nicht die komplette Entity mit allen Relationen. Erst wenn checkNotModified() false zurückgibt, lohnt sich die teure findById()-Abfrage.

Genau das übersiehst du, wenn du stattdessen auf Spring Boots eingebauten ShallowEtagHeaderFilter setzt:

// Anti-Pattern für teure Endpunkte: Der Filter berechnet den ETag erst,
// NACHDEM die komplette Business-Logik gelaufen ist
@Bean
public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
    FilterRegistrationBean<ShallowEtagHeaderFilter> filter = new FilterRegistrationBean<>();
    filter.setFilter(new ShallowEtagHeaderFilter());
    filter.addUrlPatterns("/products/*");
    return filter;
}

Der Filter hasht die fertige Response, um das ETag zu erzeugen – das spart Bandbreite bei einem 304, aber nicht die Rechenzeit, denn getProduct() läuft trotzdem komplett durch. Für simple, günstige Endpunkte ist das ein bequemer Weg ohne eigenen Code. Für Endpunkte mit teuren Datenbank-Joins oder Berechnungen willst du den manuellen Weg von oben, bei dem du die Kosten schon vor der eigentlichen Arbeit erkennst.

Hast du kein Versionsfeld zur Hand, aber einen updatedAt-Zeitstempel, ist Last-Modified die einfachere Alternative – etwas weniger präzise (Sekundengenauigkeit statt exaktem Vergleich), dafür ohne zusätzliches Feld:

@GetMapping("/articles/{id}")
public ResponseEntity<Article> getArticle(@PathVariable Long id, WebRequest request) {
    Instant lastModified = articleService.findLastModified(id);

    if (request.checkNotModified(lastModified.toEpochMilli())) {
        return null;
    }

    Article article = articleService.findById(id);
    return ResponseEntity.ok()
            .lastModified(lastModified)
            .body(article);
}

Beide Mechanismen – ETag und Last-Modified – funktionieren unabhängig voneinander und lassen sich auch kombinieren. Was in dieser Kette bisher fehlt: Was passiert, sobald zwischen deinem Client und deinem Server noch ein Proxy oder ein CDN sitzt? Genau da drüben schauen wir uns jetzt an.

Caching zwischen Client, Proxy und CDN

Bisher haben wir nur über den Cache im Browser gesprochen – den privaten Cache, der ausschließlich einem einzigen Nutzer gehört. Sobald ein Reverse Proxy oder ein CDN zwischen deinem Client und deinem Server sitzt, kommt eine zweite Kategorie ins Spiel: der geteilte Cache, der eine Antwort für mehrere Nutzer gleichzeitig vorhält. Und genau hier wird private vs. public vom netten Detail zum Sicherheitsproblem.

Stell dir einen personalisierten Dashboard-Endpunkt vor:

// Anti-Pattern: personalisierte Daten landen im geteilten CDN-Cache
@GetMapping("/dashboard")
public ResponseEntity<Dashboard> getDashboard(@AuthenticationPrincipal User user) {
    Dashboard dashboard = dashboardService.build(user);
    return ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(Duration.ofMinutes(1)).cachePublic())
            .body(dashboard);
}

Die URL ist für alle Nutzer identisch – /dashboard. Markierst du die Antwort als public, sieht ein CDN oder Reverse Proxy davor keinen Grund, sie nicht zu cachen: Aus seiner Sicht ist es eine ganz normale, teilbare Ressource. Der erste Nutzer bekommt sein Dashboard, der CDN-Knoten speichert die Antwort – und der nächste Nutzer, der über denselben Edge-Node geht, bekommt möglicherweise fremde Daten ausgeliefert. Kein Bug im Sinne einer Exception, sondern ein handfestes Datenleck, das in keinem Log als Fehler auffällt.

Die Lösung ist die andere Direktive aus der Tabelle vom Anfang:

@GetMapping("/dashboard")
public ResponseEntity<Dashboard> getDashboard(@AuthenticationPrincipal User user) {
    Dashboard dashboard = dashboardService.build(user);
    return ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(Duration.ofMinutes(1)).cachePrivate())
            .body(dashboard);
}

private sagt geteilten Caches explizit: “Diese Antwort ist nur für den anfragenden Client gültig, fasst sie nicht an.” Nur der Browser-Cache des einzelnen Nutzers darf sie speichern.

Bei Ressourcen, die zwar für alle gleich sind, sich aber nach einem Request-Header richten, brauchst du ein drittes Werkzeug: den Vary-Header. Ein Produkt-Endpunkt mit Sprachversion ist ein gutes Beispiel – die Daten sind öffentlich, aber ihr Inhalt hängt von Accept-Language ab:

@GetMapping("/products/{id}")
public ResponseEntity<Product> getProduct(
        @PathVariable Long id,
        @RequestHeader(value = "Accept-Language", required = false) String language) {

    Product product = productService.findById(id, language);
    return ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(Duration.ofMinutes(10)).cachePublic())
            .header(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE)
            .body(product);
}

Vary: Accept-Language weist jeden geteilten Cache an, für dieselbe URL mehrere Versionen vorzuhalten – eine pro Sprachwert – statt fälschlicherweise die deutsche Antwort auch an englischsprachige Clients auszuliefern. Ohne diesen Header würde der CDN die erste Sprachversion cachen und sie für alle nachfolgenden Requests auf dieselbe URL ausliefern, unabhängig vom tatsächlichen Accept-Language.

Damit hast du das Handwerkszeug für alle drei Ebenen zusammen: Freshness und Validation für die Grundstrategie, ETag/Last-Modified für die Umsetzung, private/public/Vary für die Absicherung in geteilten Caches. Bevor es ins Fazit geht, noch ein paar konkrete Fallen, in die du beim Umsetzen laufen kannst.

Pro-Tipps / Warnungen

Warnung: Cache-Control: no-cache bedeutet nicht “nicht cachen”, sondern “cachen, aber immer revalidieren”. Willst du wirklich verhindern, dass eine Antwort überhaupt gespeichert wird – etwa bei Zahlungsdaten oder Session-Informationen – brauchst du no-store. Verwechselst du die beiden, landen sensible Daten im Cache, nur eben mit einem Validierungsschritt davor.

Warnung: Vary: * wirkt wie die “sichere” Variante, wenn du dir nicht sicher bist, wovon deine Antwort abhängt – ist aber das Gegenteil. Laut Spezifikation signalisiert *, dass die Antwort von Faktoren abhängt, die ein Cache nicht kennen kann, und macht sie für geteilte Caches damit faktisch uncachebar:

// Anti-Pattern: "sicherheitshalber" alles varyen lassen
return ResponseEntity.ok()
        .header(HttpHeaders.VARY, "*")
        .body(product);

Damit ist dein gesamtes CDN-Setup aus dem letzten Abschnitt wirkungslos, ohne dass irgendwo ein Fehler auftaucht – der Cache tut einfach nichts. Nenn stattdessen konkret, wovon die Antwort abhängt: Vary: Accept-Language, Accept-Encoding.

Tipp: ETags gibt es in zwei Varianten – strong ("42") und weak (W/"42"). Ein starkes ETag garantiert Byte-für-Byte-Identität, ein schwaches nur semantische Gleichheit. Das ist relevant, sobald zwischen deinem Handler und dem Client noch etwas die Bytes verändert, zum Beispiel Gzip-Kompression – dann kann ein strong ETag ungewollt bei jedem Request wechseln, obwohl sich am Inhalt nichts geändert hat:

// Weak ETag: tolerant gegenüber Kompression, Whitespace-Änderungen etc.
return ResponseEntity.ok()
        .eTag("W/\"" + meta.version() + "\"")
        .body(product);

Der Haken dabei: Für If-Match – also Preconditions bei schreibenden Requests wie PUT oder DELETE, mit denen du Lost Updates verhinderst – verlangt die Spec zwingend ein strong ETag. Ein weak ETag ist dafür nicht zulässig. Für reines Read-Caching reicht weak meist völlig aus; sobald du ETags für Concurrency Control brauchst, merk dir das für den nächsten Artikel vor.

Fazit

Caching-Header sind kein Bereich, den du einmal aus einem Tutorial kopierst und dann vergisst. Die drei Ebenen – Freshness vs. Validation, ETag/Last-Modified als Umsetzung, private/public/Vary als Absicherung in geteilten Caches – greifen ineinander, und jede einzelne kann falsch verstanden ein Verhalten erzeugen, das genau das Gegenteil von dem ist, was du wolltest: kein Caching, wo du welches wolltest, oder Caching, wo eigentlich keins hingehört.

Der Aufwand lohnt sich trotzdem. Richtig eingesetzt sparen Caching-Header nicht nur Bandbreite und Serverlast, sondern auch echte Rechenzeit – wie du beim manuellen ETag-Beispiel gesehen hast, kannst du teure Datenbankabfragen komplett überspringen, wenn sich ohnehin nichts geändert hat.

Damit sind wir bei einem Detail aus den Pro-Tipps hängen geblieben: Strong ETags spielen nicht nur beim Lesen eine Rolle, sondern auch beim Schreiben – über If-Match lassen sich damit Lost Updates verhindern, wenn zwei Clients gleichzeitig dieselbe Ressource ändern wollen. Genau darum geht’s im nächsten Teil unserer Serie: Conditional Writes und Optimistic Concurrency Control mit HTTP-Preconditions.