In the first part of our series, we looked at QUERY, the first new HTTP method in 15 years. Today we’re talking about something you probably use every day without really understanding what you’re doing with it: caching headers.
You know that - you google “spring boot http caching”, find a Stack Overflow post or tutorial, copy Cache-Control: no-cache into your response header and think you’ve now turned off caching? The opposite is the case. The browser still caches the response - it just asks politely with each request whether it is still up to date.
Exactly such misunderstandings are why caching headers are one of the most misused parts of HTTP. Not because the spec is complicated, but because the terms intuitively suggest something different than what they actually mean. The result: APIs that unintentionally recalculate every request even though they could be cached - or worse, APIs that deliver outdated data because a cache somewhere in the chain is holding things longer than expected.
In this article we’ll look at how HTTP caching really works: the difference between freshness and validation, how to use ETags and conditional requests in Spring Boot, and what to look out for when proxies or CDNs sit between your client and your server.
Freshness vs. Validation – the two caching strategies
At its core, HTTP has two answers to the question “can a cache reuse this answer without asking the server?” – and most of the confusion arises because both responses go through the same header: Cache-Control.
Freshness means: The cache can simply reuse the answer for a certain period of time, without any contact with the server. You control this with max-age:
Cache-Control: max-age=300, public
The client receives the response directly from the cache for five minutes - the server does not see any requests during this time.
Validation, on the other hand, means: The cache is allowed to keep the answer, but each time it is used it must first ask the server whether it is still up to date. That’s exactly what “no-cache” is – and here lies the trap from the introduction:
// 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);
}
The name suggests “no caching”. What actually happens is that the browser saves the response anyway, but sends a conditional request for each subsequent request (more on that in a moment) and ideally only gets a slim 304 back instead of the full response. That’s not wrong - but if you actually don’t want caching, for example for sensitive data, you need a different directive:
// 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 is the only directive that actually means “do not store, under any circumstances”.
For data that rarely changes and does not need an exact real-time status, freshness-based caching with max-age is the better choice because it completely saves the server from having to be asked:
@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);
}
The short rule to remember for your stack: max-age saves the request completely, no-cache only saves the payload (thanks to validation), no-store saves nothing - and that’s exactly the point with sensitive data.
| Directive | Meaning |
|---|---|
max-age=N | Cache is allowed to reuse N seconds without query |
no-cache | Cache must be revalidated before each use (not: “do not cache”) |
no-store | Save nothing, neither client nor shared cache |
private | Browser cache only, not in shared caches (proxy, CDN) |
public | Also allowed in shared caches |
In order for no-cache to work meaningfully, the server needs a way to answer “still current” quickly and cheaply - that is the role of ETags and conditional requests, which we will look at next.
Validation in der Praxis: ETag, Last-Modified & Conditional Requests
At its core, an ETag is nothing more than a fingerprint of a specific version of your resource - a string that the client and server can compare without retransmitting the entire response. The process:
- Server returns a response with
ETag: "42". - With the next request, the client sends
If-None-Match: "42". - Server compares: Does the ETag still fit? Then he replies with 304 Not Modified – without any body. If it no longer fits, there is the full answer with a new ETag.
This saves the client the transmission, although the server continues to be asked for every request - this is exactly the behavior that makes no-cache from the last section useful.
In Spring Boot, WebRequest.checkNotModified() does the complete comparison for you:
@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);
}
The crucial point here: findMeta() only reads a version field from the database, not the complete entity with all relations. Only when checkNotModified() returns false is the expensive findById() query worth it.
This is exactly what you’re missing if you use Spring Boots’ built-in ShallowEtagHeaderFilter instead:
// 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;
}
The filter hashes the finished response to create the ETag - this saves bandwidth with a 304, but not the computing time, because getProduct() still runs completely. For simple, inexpensive endpoints, this is a convenient way without your own code. For endpoints with expensive database joins or calculations, you want the manual approach from the top, where you recognize the costs before the actual work is done.
If you don’t have a version field at hand, but you do have an updatedAt timestamp, Last-Modified is the simpler alternative - slightly less precise (second precision instead of exact comparison), but without an additional field:
@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);
}
Both mechanisms – ETag and Last-Modified – work independently of each other and can also be combined. What’s missing from this chain so far: What happens as soon as there’s a proxy or CDN between your client and your server? Right over there we’re looking at now.
Caching between client, proxy and CDN
So far we have only talked about the cache in the browser - the private cache, which belongs exclusively to a single user. Once a reverse proxy or CDN sits between your client and your server, a second category comes into play: the shared cache, which holds a response for multiple users at the same time. And this is exactly where private vs. public turns from a nice detail into a security problem.
Imagine a personalized dashboard endpoint:
// 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);
}
The URL is the same for all users – /dashboard. If you mark the response as ‘public’, a CDN or reverse proxy sees no reason not to cache it: from its point of view, it is a completely normal, shareable resource. The first user gets their dashboard, the CDN node saves the answer - and the next user who goes through the same edge node may receive third-party data. Not a bug in the sense of an exception, but a tangible data leak that is not noticeable as an error in any log.
The solution is the other directive from the table at the beginning:
@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 explicitly tells shared caches: “This response is only valid for the requesting client, do not touch it.” Only the individual user’s browser cache is allowed to store them.
For resources that are the same for everyone but follow a request header, you need a third tool: the Vary header. A product endpoint with a language version is a good example - the data is public, but its content depends on Accept-Language:
@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 instructs each shared cache to maintain multiple versions of the same URL - one per language value - instead of incorrectly serving the German response to English-speaking clients. Without this header, the CDN would cache the first language version and serve it to the same URL for all subsequent requests, regardless of the actual Accept-Language.
This gives you the tools for all three levels: freshness and validation for the basic strategy, ETag/Last-Modified for implementation, private/public/Vary for protection in shared caches. Before we get to the conclusion, here are a few concrete traps that you can run into when implementing it.
Pro Tips/Warnings
Warning:
Cache-Control: no-cachedoes not mean “do not cache”, but rather “cache but always revalidate”. If you really want to prevent a response from being stored at all - for example payment data or session information - you need ’no-store’. If you confuse the two, sensitive data ends up in the cache, just with a validation step in front of it.
Warning:
Vary: *seems like the “safe” option if you’re not sure what your answer depends on - but it’s the opposite. According to the spec,*signals that the answer depends on factors that a cache cannot know, making it effectively uncacheable for shared caches:// Anti-pattern: let everything vary "to be on the safe side". return ResponseEntity.ok() .header(HttpHeaders.VARY, "*") .body(product);This means that your entire CDN setup from the last section is ineffective without an error appearing anywhere - the cache simply does nothing. Instead, state specifically what the answer depends on:
Vary: Accept-Language, Accept-Encoding.
Tip: ETags come in two variants – strong (
"42") and weak (W/"42"). A strong ETag guarantees byte-by-byte identity, a weak one only guarantees semantic equality. This is relevant as soon as something changes the bytes between your handler and the client, for example Gzip compression - then a strong ETag can unintentionally change with every request, even though nothing in the content has changed:// Weak ETag: tolerant of compression, whitespace changes, etc. return ResponseEntity.ok() .eTag("W/\"" + meta.version() + "\"") .body(product);The catch: For ‘If-Match’ - i.e. preconditions for writing requests such as PUT or DELETE, with which you prevent lost updates - the spec requires a strong ETag. A weak ETag is not permitted for this. For pure read caching, weak is usually sufficient; As soon as you need ETags for concurrency control, save it for the next article.
Conclusion
Caching headers are not an area that you copy from a tutorial once and then forget about. The three levels - Freshness vs. Validation, ETag/Last-Modified as implementation, private/public/Vary as protection in shared caches - interlock, and each one, if misunderstood, can produce behavior that is exactly the opposite of what you wanted: no caching where you wanted it, or caching where there shouldn’t be any.
The effort is still worth it. When used correctly, caching headers not only save bandwidth and server load, but also real computing time - as you saw with the manual ETag example, you can completely skip expensive database queries if nothing has changed anyway.
This brings us to a detail from the pro tips: Strong ETags not only play a role when reading, but also when writing - using ‘If-Match’ you can prevent lost updates if two clients want to change the same resource at the same time. That’s exactly what the next part of our series is about: Conditional Writes and Optimistic Concurrency Control with HTTP preconditions.
![[EN] HTTP Caching Headers: More than Cache Control from the tutorial](/images/http-caching-header-header.jpeg)