In this tutorial we will learn Spring's support for 'ETag' and 'If-None-Match' headers. Check out the general step-by-step usage of these headers. It is also recommended to check out the related servlet tutorial to have a good understanding of setting these headers at a low level.
If you have read our tutorial on setting 'Last-Modified' and 'If-Modified-Since', then you will find this tutorial very similar, that's because both approaches ultimately achieve the same goal using a very similar API. ETag is considered a more generic way to utilize client-side caching than the Last-Modified header.
Spring provides the following ways to work with these headers.
Using WebRequest#checkNotModified()
The following methods of WebRequest transparently check the value of the 'If-None-Match' conditional request header and set the 'ETag' value in the response header as needed.
boolean checkNotModified(String etag)
boolean checkNotModified(String etag,
long lastModifiedTimestamp)
The second method allows working with both the 'ETag' and 'Last-Modified' approaches at the same time.
We can access the WebRequest object, or its implementation ServletWebRequest, by having it as a parameter of the @RequestMapping methods.
Returning ResponseEntity<T> after setting an ETag value
Using the following method of ResponseEntity.BodyBuilder will set the response's 'ETag' header.
B eTag(String eTag)
When the corresponding ResponseEntity is returned from the handler method, the 'ETag' header will be populated, and the response will also be converted to an HTTP 304 Not Modified with an empty body if the conditional header 'If-None-Match' sent by the client is the same as the current ETag value of the resource. Obviously this approach does not save controller processing cycles, because the full response must still be computed for each request, but it still saves bandwidth by not sending the full response body to the client. If interested, check out the methods handleReturnValue() and isResourceNotModified() of HttpEntityMethodProcessor.java
Example
Resource Store
package com.logicbig.example;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.atomic.AtomicReference;
@Service
public class ResourceStore {
private final AtomicReference<Resource> resourceRef =
new AtomicReference<>(new Resource("initial-data", 1));
public Resource getCurrentResource() {
return resourceRef.get();
}
public Resource update(String newData) {
return resourceRef.updateAndGet(
current -> new Resource(newData,
current.getVersion() + 1));
}
public static class Resource {
private String data;
private int version;
public Resource(String data,
int version) {
this.data = data;
this.version = version;
}
public String getData() {
return data + ", read at " +
LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS);
}
public int getVersion() {
return version;
}
}
}
Controller
package com.logicbig.example;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;
@Controller
public class TheController {
@Autowired
private ResourceStore resourceStore;
@ResponseBody
@RequestMapping(value = "/test1")
public String handle1(ServletWebRequest swr) {
if (swr.checkNotModified(getETag())) {
//it will return 304 with empty body
return null;
}
return resourceStore.getCurrentResource().getData();
}
@ResponseBody
@RequestMapping(value = "/test2")
public ResponseEntity<String> handle2(WebRequest swr) {
return ResponseEntity
.ok()
.eTag(getETag())
.body(resourceStore.getCurrentResource().getData());
}
@ResponseBody
@RequestMapping(value = "/update", method = RequestMethod.POST)
public String updateResource(@RequestBody String content) {
resourceStore.update(content);
return "Resource updated";
}
public String getETag() {
return "version" +
resourceStore.getCurrentResource().getVersion();
}
}
Filter to log request and response headers
@WebFilter(urlPatterns = "/*")
public class HeaderLogger implements Filter {
@Override
public void init (FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter (ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse rep = (HttpServletResponse) response;
System.out.println("----- Request ---------");
Collections.list(req.getHeaderNames())
.forEach(n -> System.out.println(n + ": " + req.getHeader(n)));
chain.doFilter(request, response);
System.out.println("----- response ---------");
rep.getHeaderNames()
.forEach(n -> System.out.println(n + ": " + rep.getHeader(n)));
System.out.println("response status: " + rep.getStatus());
}
@Override
public void destroy () {
}
}
Example Outputs
To try examples, run embedded Jetty (configured in pom.xml of example project below):
mvn jetty:run
/test1
 $ curl -s -o page.html --etag-save etag.txt --etag-compare etag.txt "http://localhost:8080/test1" && type page.html initial-data, read at 2026-09-20T10:19:24.302
Server Output ----- Request --------- If-None-Match: "" Accept: */* User-Agent: curl/8.21.0 Host: localhost:8080 ----- response --------- ETag: "version1" Content-Length: 45 Date: Sun, 20 Sep 2026 15:19:24 GMT Content-Type: text/plain;charset=iso-8859-1 response status: 200
In above curl command, -o <file> saves curl's output to the given file instead of printing it to stdout. --etag-save <file> tells curl to extract the response's ETag header and save it to the given file, which can later be used for conditional requests. --etag-compare <file> reads a previously saved ETag from the given file and sends it as an If-None-Match header, so the server can respond with 304 Not Modified and with empty body, if the resource hasn't changed, letting curl use the previously saved file by -o option.
Accessing /test1 again:
 $ curl -s -o page.html --etag-save etag.txt --etag-compare etag.txt "http://localhost:8080/test1" && type page.html initial-data, read at 2026-09-20T10:19:24.302
Server Output ----- Request --------- If-None-Match: "version1" Accept: */* User-Agent: curl/8.21.0 Host: localhost:8080 ----- response --------- ETag: "version1" Date: Sun, 20 Sep 2026 15:19:49 GMT response status: 304
Modify the resource
Let's call /update to modify the content which will also increase the version (etag value)
$ curl -s -X POST "http://localhost:8080/update" -H "Content-Type: text/plain;charset=UTF-8" --data "updated test data" Resource updated
Now accessing /test1 again will return the new body with etag header.
 $ curl -s -o page.html --etag-save etag.txt --etag-compare etag.txt "http://localhost:8080/test1" && type page.html updated test data, read at 2026-09-20T10:20:51.539
Server Output ----- Request --------- If-None-Match: "version1" Accept: */* User-Agent: curl/8.21.0 Host: localhost:8080 ----- response --------- ETag: "version2" Content-Length: 50 Date: Sun, 20 Sep 2026 15:20:51 GMT Content-Type: text/plain;charset=iso-8859-1 response status: 200
Accessing /test1 again will return 304 with empty body and curl will use the local cache file.
 $ curl -s -o page.html --etag-save etag.txt --etag-compare etag.txt "http://localhost:8080/test1" && type page.html && del page.html && del etag.txt updated test data, read at 2026-09-20T10:20:51.539
Server Output ----- Request --------- If-None-Match: "version2" Accept: */* User-Agent: curl/8.21.0 Host: localhost:8080 ----- response --------- ETag: "version2" Date: Sun, 20 Sep 2026 15:21:03 GMT response status: 304
/test2
The outcome will be similar for the same actions we used with /test1.
Note that for /test2, the corresponding handler method returns a ResponseEntity after setting the ETag header. This approach is more transparent, as we don't have to check the ETag validity manually by using WebRequest#checkNotModified(), but there's no way to avoid creating the full response in the handler every time, so this approach should be avoided if creating the response body is a very expensive process.
No implicit support of ETag for static pages
In the case of static resources, there is no implicit support for 'ETag' at the servlet-container level. 'Last-Modified' response header support is always active for static pages; in that case, the File object's lastModified date is used. For ETag, we can perhaps create a custom filter to intercept static pages and generate a hash code, based on the content of the page, that can be used as the ETag header value. Spring provides such a filter out of the box, ShallowEtagHeaderFilter. We will see an example of ShallowEtagHeaderFilter in the next tutorial.
Example ProjectDependencies and Technologies Used: - spring-webmvc 7.0.6 (Spring Web MVC)
Version Compatibility: 4.3.0.RELEASE - 7.0.6 Version compatibilities of spring-webmvc with this example: Versions in green have been tested.
- jakarta.servlet-api 6.1.0 (Jakarta Servlet API documentation)
- JDK 25
- Maven 3.9.11
|