Skip to main content

Mastering Cache Control with ETag in Spring Boot RESTful APIs

ETag cache control in a Spring Boot REST API, re-verified on Spring Boot 4.1.1 and Spring Framework 7.0.9: the zero-code ShallowEtagHeaderFilter, deep caching with WebRequest.checkNotModified(), conditional PUT with If-Match, and strong vs weak ETags.

Fetch the same product twice in a row and a typical REST API will do the same work twice — run the same query, serialize the same JSON, send the same bytes over the wire — even when nothing changed between the two requests. An ETag (entity tag) is how HTTP lets a server tell a client “you already have this, and here is a fingerprint to prove it,” so the second request can come back nearly free. This post rebuilds ETag support in a Spring Boot REST API from the zero-code filter option through to true deep caching and optimistic-locking updates, re-verified end to end on Spring Boot 4.1.1 and Spring Framework 7.0.9. The APIs themselves have not changed since the original version of this article — what changed is one starter’s name, and that fact is checked against Maven Central directly rather than assumed.
Versions used in this post, verified with mvn dependency:tree against a throwaway spring-boot-starter-parent:4.1.1 project.

Spring Boot 4.1.1 · Spring Framework 7.0.9 · JDK 25
All the code below lives in a real, tested companion module: etag-caching in the spring-boot-demo repository. Every curl transcript quoted is a JUnit assertion, not a hand-typed example.

The problem: paying full price for an answer that hasn’t changed

A client polling GET /api/products/42 once a minute, most of the time, gets back the exact same product. Without any caching mechanism, the server still runs the full query, still builds the full JSON body, and still ships the full response — every single time, even on the requests where nothing happened. An ETag fixes this by turning “has this changed?” into a cheap yes/no question the client can ask before paying for the full answer:
  • Save bandwidth — on a match, the server sends a 304 Not Modified with no body at all.
  • Reduce server load — a 304 response path is far lighter than fully re-serializing and transferring the resource.
  • Prevent lost updates — a PUT carrying If-Match: <etag> fails with 412 Precondition Failed if someone else changed the resource first, instead of silently overwriting their change.
The rest of this post builds all three, in order of how much of the request they actually save.

The mental model: a fingerprint, sent back and forth

Client Server GET /api/products/42 200 OK • ETag: "4e47fa7e" • full body GET /api/products/42, If-None-Match: "4e47fa7e" 304 Not Modified • empty body The client sends the fingerprint back; the server compares it and, on a match, sends nothing but the status line. The second request is nearly free precisely because the server never re-serializes or re-sends the body — it only has to answer “does this fingerprint still match?” The next three sections build three different ways to answer that question, from zero code to full control over what work happens before the comparison.

The zero-code option: ShallowEtagHeaderFilter

Spring MVC ships a filter that computes the ETag for you, from the response your controller already produced: Source: WebConfig.java
@Bean
public FilterRegistrationBean<Filter> shallowEtagHeaderFilter() {
    FilterRegistrationBean<Filter> registration =
            new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
    registration.addUrlPatterns("/api/echo/*");
    return registration;
}
org.springframework.web.filter.ShallowEtagHeaderFilter — same package, same behaviour on Spring Framework 7.0.9 as on Boot 3, confirmed by compiling and running against it rather than assumed from the earlier version of this article. It hashes the full response body with MD5 after your handler has already produced it, writes the result as the ETag header, and turns a matching If-None-Match into a 304 with no further code from you: Output: 05-shallow-etag-header-filter.txt
$ curl -i http://localhost:8080/api/echo/hello

First request  -> status 200, ETag "04b614fb02225a0cb24f7520b58e80cb1", body 'echo: hello'

$ curl -i http://localhost:8080/api/echo/hello -H 'If-None-Match: "04b614fb02225a0cb24f7520b58e80cb1"'

Second request -> status 304
This module registers the filter with FilterRegistrationBean, scoped to one URL pattern, rather than as a plain @Bean, which applies it globally and can shadow more deliberate caching logic on other endpoints — the next section needs exactly that room.
This is bandwidth savings, not work savings. The filter only computes its hash after the handler already ran — a database query, a slow downstream call, anything your handler does still happens on every request, match or not. What you save is the bytes sent back over the wire on a 304, not the cost of producing them.

Deep caching: checking before the expensive part runs

To skip the actual work — not just the bytes on the wire — the comparison has to happen before the handler does anything costly. WebRequest.checkNotModified(String) is built for exactly that: give it the ETag value, and if it matches what the client sent, Spring writes the 304 itself and your method can return without doing the expensive part. Source: ProductController.java
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable long id, WebRequest webRequest) {
    Product current = productService.findById(id);
    String etagValue = productService.etagFor(current);

    if (webRequest.checkNotModified(etagValue)) {
        return null; // 304 already written
    }
    return ResponseEntity.ok().eTag(etagValue).body(current);
}
Output: 01-first-get-returns-etag.txt, 02-conditional-get-304.txt
$ curl -i http://localhost:8080/api/products/42

HTTP status: 200
ETag: "4e47fa7e"

$ curl -i http://localhost:8080/api/products/42 -H 'If-None-Match: "4e47fa7e"'

HTTP status: 304
Body: '' (empty)
In this module, findById is a map read, so the “expensive part” is illustrative rather than measured — the design point that matters in a real service is where you put the line. You need a way to derive the comparison value — a stored version column, a last_modified timestamp — that is genuinely cheaper than running the full query the ETag exists to let you skip. Get that split wrong and “deep” caching quietly degrades back to “shallow” in every way that matters, while still looking like it should be faster.

Conditional updates: If-Match as optimistic locking

The same fingerprint that saves reads can also protect writes. A PUT that includes If-Match tells the server “only apply this update if the resource still looks like what I last read” — a stale value is rejected before the write happens, instead of silently overwriting someone else’s change: Output: 03-conditional-put-412.txt
$ curl -i -X PUT http://localhost:8080/api/products/42 \
     -H 'Content-Type: application/json' -H 'If-Match: "stale-etag-from-a-while-ago"' \
     -d '{"name":"Laptop Pro","price":1199}'

HTTP status: 412
Current ETag header returned: "4e47fa7e"
Supply the current ETag instead, and the update succeeds — and the ETag rotates to a new value derived from the new content, so a second request reusing the old If-Match now 412s too: Output: 04-conditional-put-success.txt
$ curl -i -X PUT http://localhost:8080/api/products/42 \
     -H 'Content-Type: application/json' -H 'If-Match: "4e47fa7e"' \
     -d '{"name":"Laptop Pro","price":1199}'

HTTP status: 200
New ETag: "0087226b" (rotated -- a stale If-Match sent after this point 412s)
This module builds the ETag by hand from a version counter, to keep the example self-contained. A JPA entity’s own @Version column is the natural real-world source for the same value — hash it, or use it directly as a weak ETag, instead of re-deriving a content hash on every request.

Strong vs weak ETags

Everything above uses strong ETags — the format HTTP treats as a promise of byte-for-byte identity. A weak ETag makes a looser promise, useful when two responses are semantically the same but not guaranteed identical down to the byte:
TypeFormatMeaning
Strong"abc123"Two responses with the same strong ETag are byte-for-byte identical.
WeakW/"abc123"Semantically equivalent, but may differ in whitespace, header order, or other content the client does not care about. Use when exact byte equality is not required or not achievable.
WebRequest.checkNotModified() accepts either form; a strong ETag is the right default unless you have a specific reason your representation cannot be produced identically twice.

Should you reach for this?

Start with the shallow filter on your highest-traffic read endpoints — it costs one bean and a URL pattern, and it saves bandwidth immediately. Move to the deep-cache pattern only once you know the underlying fetch is actually expensive enough to matter, since it asks you to maintain a cheap comparison value alongside the real data. Add If-Match to any endpoint where two clients might race to update the same resource — it is a few lines and it turns a silent lost update into an explicit, retryable 412.
ETags remain one of the most underused caching mechanisms available in a REST API, largely because they are easy to bolt on halfway and stop there. The shallow filter, the deep-cache check, and conditional writes are each a small, separate addition — and together they make an API both faster and safer against concurrent writers.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.