Mastering Cache Control with ETag in Spring Boot RESTful APIs

An ETag (Entity Tag) is a short fingerprint — typically an MD5 or SHA hash of a resource’s content — that a REST API includes in every response. On the next request, the client sends the ETag back in a If-None-Match header. If the resource has not changed, the server replies with 304 Not Modified and an empty body, saving bandwidth and time. In this guide you will learn how ETags work, implement them from scratch in a Spring Boot REST API, and apply conditional updates to prevent lost updates in concurrent systems.

Why ETags?

  • Save bandwidth — the server skips serialising and transferring unchanged data.
  • Reduce server load — the response processing path for a 304 is much lighter than a full 200.
  • Prevent lost updates — a PUT that includes If-Match: <etag> fails with 412 if someone else modified the resource first, preventing a silent overwrite.

How the ETag Flow Works

-- First request --
GET /api/products/42
<- 200 OK
   ETag: "d41f3a9b"
   Body: { "id": 42, "name": "Laptop", "price": 999 }

-- Second request (client caches the ETag and sends it back) --
GET /api/products/42
    If-None-Match: "d41f3a9b"
<- 304 Not Modified   (empty body, full headers)

-- After resource changes --
GET /api/products/42
    If-None-Match: "d41f3a9b"
<- 200 OK
   ETag: "c7e2190a"   (new hash)
   Body: { "id": 42, "name": "Laptop", "price": 1099 }

Implementation in Spring Boot

Step 1 — Add the ShallowEtagHeaderFilter (Zero-code option)

Spring MVC ships a ShallowEtagHeaderFilter that automatically computes an MD5 ETag from the response body. Register it as a Spring bean:

import org.springframework.web.filter.ShallowEtagHeaderFilter;
import jakarta.servlet.Filter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class WebConfig {

    @Bean
    public Filter shallowEtagHeaderFilter() {
        // Applies to all requests automatically
        return new ShallowEtagHeaderFilter();
    }
}

With this single bean, every GET response automatically gains an ETag header and Spring handles 304 Not Modified responses. This is the quickest approach but it still executes your full controller logic — it only saves the response transfer.

Step 2 — Manual ETag with Request.checkNotModified() (Deep Cache)

For true performance savings — skipping database queries entirely — check the ETag before fetching data using WebRequest.checkNotModified():

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.WebRequest;

import java.security.MessageDigest;
import java.util.HexFormat;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> getProduct(
            @PathVariable long id,
            WebRequest webRequest) {

        // 1. Fetch lightweight version info (e.g. last-modified timestamp or version number)
        String etagValue = '"' + productService.getEtag(id) + '"';

        // 2. Check If-None-Match header — return 304 if nothing changed
        if (webRequest.checkNotModified(etagValue)) {
            return null;  // Spring writes 304 Not Modified automatically
        }

        // 3. Only fetch full data if the ETag is stale
        Product product = productService.findById(id);
        return ResponseEntity.ok()
            .eTag(etagValue)
            .body(product);
    }

    /** Compute ETag from content hash */
    static String computeEtag(String content) {
        try {
            byte[] digest = MessageDigest.getInstance("MD5")
                                         .digest(content.getBytes());
            return HexFormat.of().formatHex(digest).substring(0, 8);
        } catch (Exception e) {
            return String.valueOf(content.hashCode());
        }
    }
}

Step 3 — Conditional Updates with If-Match

Use If-Match on PUT requests to implement optimistic locking. If the client’s ETag no longer matches the server’s, the update is rejected with 412 Precondition Failed:

@PutMapping("/{id}")
public ResponseEntity<Product> updateProduct(
        @PathVariable long id,
        @RequestBody Product updated,
        @RequestHeader(value = "If-Match", required = false) String ifMatch) {

    String currentEtag = '"' + productService.getEtag(id) + '"';

    // Reject if client's ETag doesn't match current state
    if (ifMatch != null && !ifMatch.equals(currentEtag)) {
        return ResponseEntity.status(412)   // 412 Precondition Failed
            .header("ETag", currentEtag)
            .build();
    }

    Product saved = productService.update(id, updated);
    String newEtag = '"' + productService.getEtag(id) + '"';

    return ResponseEntity.ok()
        .eTag(newEtag)
        .body(saved);
}

Testing with curl

# 1. First GET — captures the ETag
curl -i http://localhost:8080/api/products/42
# ...ETag: "d41f3a9b"

# 2. Conditional GET — 304 if unchanged
curl -i http://localhost:8080/api/products/42 
     -H 'If-None-Match: "d41f3a9b"'
# HTTP/1.1 304 Not Modified

# 3. Conditional PUT — 412 if stale
curl -i -X PUT http://localhost:8080/api/products/42 
     -H 'Content-Type: application/json' 
     -H 'If-Match: "old-stale-etag"' 
     -d '{"name":"Laptop Pro","price":1199}'
# HTTP/1.1 412 Precondition Failed

Strong vs Weak ETags

TypeFormatMeaning
Strong"abc123"Two responses with the same strong ETag are byte-for-byte identical.
WeakW/"abc123"Semantically equivalent but may differ in whitespace or header order. Use when exact byte equality is not required.

See Also

Conclusion

ETags are one of the most valuable and underused HTTP caching mechanisms in REST APIs. The ShallowEtagHeaderFilter gets you automatic ETags with zero code changes, while the manual WebRequest.checkNotModified() approach skips database calls entirely for unchanged resources. Add If-Match support to PUT and DELETE endpoints to get optimistic locking for free — preventing silent data loss in concurrent environments. With these three tools combined, your API will be both faster and safer.

Leave a Reply

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