Close

Spring MVC - How to set 'ETag' and 'If-None-Match' headers?

[Last Updated: Sep 20, 2026]

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 Project

Dependencies and Technologies Used:

  • spring-webmvc 7.0.6 (Spring Web MVC)
     Version Compatibility: 4.3.0.RELEASE - 7.0.6Version List
    ×

    Version compatibilities of spring-webmvc with this example:

      javax.servlet-api:3.x
    • 4.3.0.RELEASE
    • 4.3.1.RELEASE
    • 4.3.2.RELEASE
    • 4.3.3.RELEASE
    • 4.3.4.RELEASE
    • 4.3.5.RELEASE
    • 4.3.6.RELEASE
    • 4.3.7.RELEASE
    • 4.3.8.RELEASE
    • 4.3.9.RELEASE
    • 4.3.10.RELEASE
    • 4.3.11.RELEASE
    • 4.3.12.RELEASE
    • 4.3.13.RELEASE
    • 4.3.14.RELEASE
    • 4.3.15.RELEASE
    • 4.3.16.RELEASE
    • 4.3.17.RELEASE
    • 4.3.18.RELEASE
    • 4.3.19.RELEASE
    • 4.3.20.RELEASE
    • 4.3.21.RELEASE
    • 4.3.22.RELEASE
    • 4.3.23.RELEASE
    • 4.3.24.RELEASE
    • 4.3.25.RELEASE
    • 4.3.26.RELEASE
    • 4.3.27.RELEASE
    • 4.3.28.RELEASE
    • 4.3.29.RELEASE
    • 4.3.30.RELEASE
    • 5.0.0.RELEASE
    • 5.0.1.RELEASE
    • 5.0.2.RELEASE
    • 5.0.3.RELEASE
    • 5.0.4.RELEASE
    • 5.0.5.RELEASE
    • 5.0.6.RELEASE
    • 5.0.7.RELEASE
    • 5.0.8.RELEASE
    • 5.0.9.RELEASE
    • 5.0.10.RELEASE
    • 5.0.11.RELEASE
    • 5.0.12.RELEASE
    • 5.0.13.RELEASE
    • 5.0.14.RELEASE
    • 5.0.15.RELEASE
    • 5.0.16.RELEASE
    • 5.0.17.RELEASE
    • 5.0.18.RELEASE
    • 5.0.19.RELEASE
    • 5.0.20.RELEASE
    • 5.1.0.RELEASE
    • 5.1.1.RELEASE
    • 5.1.2.RELEASE
    • 5.1.3.RELEASE
    • 5.1.4.RELEASE
    • 5.1.5.RELEASE
    • 5.1.6.RELEASE
    • 5.1.7.RELEASE
    • 5.1.8.RELEASE
    • 5.1.9.RELEASE
    • 5.1.10.RELEASE
    • 5.1.11.RELEASE
    • 5.1.12.RELEASE
    • 5.1.13.RELEASE
    • 5.1.14.RELEASE
    • 5.1.15.RELEASE
    • 5.1.16.RELEASE
    • 5.1.17.RELEASE
    • 5.1.18.RELEASE
    • 5.1.19.RELEASE
    • 5.1.20.RELEASE
    • 5.2.0.RELEASE
    • 5.2.1.RELEASE
    • 5.2.2.RELEASE
    • 5.2.3.RELEASE
    • 5.2.4.RELEASE
    • 5.2.5.RELEASE
    • 5.2.6.RELEASE
    • 5.2.7.RELEASE
    • 5.2.8.RELEASE
    • 5.2.9.RELEASE
    • 5.2.10.RELEASE
    • 5.2.11.RELEASE
    • 5.2.12.RELEASE
    • 5.2.13.RELEASE
    • 5.2.14.RELEASE
    • 5.2.15.RELEASE
    • 5.2.16.RELEASE
    • 5.2.17.RELEASE
    • 5.2.18.RELEASE
    • 5.2.19.RELEASE
    • 5.2.20.RELEASE
    • 5.2.21.RELEASE
    • 5.2.22.RELEASE
    • 5.2.23.RELEASE
    • 5.2.24.RELEASE
    • 5.2.25.RELEASE
    • 5.3.0
    • 5.3.1
    • 5.3.2
    • 5.3.3
    • 5.3.4
    • javax.servlet-api:4.x
    • 5.3.5
    • 5.3.6
    • 5.3.7
    • 5.3.8
    • 5.3.9
    • 5.3.10
    • 5.3.11
    • 5.3.12
    • 5.3.13
    • 5.3.14
    • 5.3.15
    • 5.3.16
    • 5.3.17
    • 5.3.18
    • 5.3.19
    • 5.3.20
    • 5.3.21
    • 5.3.22
    • 5.3.23
    • 5.3.24
    • 5.3.25
    • 5.3.26
    • 5.3.27
    • 5.3.28
    • 5.3.29
    • 5.3.30
    • 5.3.31
    • 5.3.32
    • 5.3.33
    • 5.3.34
    • 5.3.35
    • 5.3.36
    • 5.3.37
    • 5.3.38
    • 5.3.39
    • javax.* -> jakarta.*
      jakarta.servlet-api:6.x
      Java 17 min
    • 6.0.0
    • 6.0.1
    • 6.0.2
    • 6.0.3
    • 6.0.4
    • 6.0.5
    • 6.0.6
    • 6.0.7
    • 6.0.8
    • 6.0.9
    • 6.0.10
    • 6.0.11
    • 6.0.12
    • 6.0.13
    • 6.0.14
    • 6.0.15
    • 6.0.16
    • 6.0.17
    • 6.0.18
    • 6.0.19
    • 6.0.20
    • 6.0.21
    • 6.0.22
    • 6.0.23
    • 6.1.0
    • 6.1.1
    • 6.1.2
    • 6.1.3
    • 6.1.4
    • 6.1.5
    • 6.1.6
    • 6.1.7
    • 6.1.8
    • 6.1.9
    • 6.1.10
    • 6.1.11
    • 6.1.12
    • 6.1.13
    • 6.1.14
    • 6.1.15
    • 6.1.16
    • 6.1.17
    • 6.1.18
    • 6.1.19
    • 6.1.20
    • 6.1.21
    • 6.2.0
    • 6.2.1
    • 6.2.2
    • 6.2.3
    • 6.2.4
    • 6.2.5
    • 6.2.6
    • 6.2.7
    • 6.2.8
    • 6.2.9
    • 6.2.10
    • 6.2.11
    • 6.2.12
    • 6.2.13
    • 6.2.14
    • 6.2.15
    • 6.2.16
    • 6.2.17
    • 6.2.18
    • 6.2.19
    • 7.0.0
    • 7.0.1
    • 7.0.2
    • 7.0.3
    • 7.0.4
    • 7.0.5
    • 7.0.6

    Versions in green have been tested.

  • jakarta.servlet-api 6.1.0 (Jakarta Servlet API documentation)
  • JDK 25
  • Maven 3.9.11

Spring MVC - 'ETag' and 'If-None-Match' headers Example Select All Download
  • etag-header-example
    • src
      • main
        • java
          • com
            • logicbig
              • example
                • TheController.java

    See Also

    Join