Spring MVC - How to set 'Last-Modified' and 'If-Modified-Since' headers?

[Updated: Feb 4, 2017, Created: Feb 2, 2017]

In this tutorial we will learn Spring support for 'Last-Modified' and 'If-Modified-Since' headers. Please check out the general step-by-step usage of these headers. We also recommend to check out the related servlet tutorial to have a good understanding of working with these headers at low level.

Spring provides following convenient ways to set these headers.

Using WebRequest#checkNotModified()

Following method of WebRequest transparently checks the value of 'If-Modified-Since' request header and sets 'Last-Modified' value in the response header as needed.

boolean checkNotModified(long lastModifiedTimestamp);

We can have WebRequest or it's implementation ServletWebRequest as a parameter of the @RequestMapping methods.

Returning ResponseEntity<T> after setting Last-Modified value

Using following method of ResponseEntity.BodyBuilder will set the response 'Last-Modified' header.

B lastModified(long lastModified);

When corresponding ResponseEntity is returned from the handler method, the required headers will be populated, and also the response will be converted to an HTTP 304 (Not Modified) with an empty body if the conditional header 'If-Modified-Since' sent by the client is same as the current modified date of the resource. Obviously this approach does not save controller processing, as the full response must be computed for each request, but it still saves bandwidth by not sending full body response to the client. If interested check out methods handleReturnValue() and isResourceNotModified() of HttpEntityMethodProcessor



Example

In this example we are going to demonstrate the usage of WebRequest#checkNotModified and ResponseEntity.BodyBuilder.lastModified(..) for the dynamic content and also how static resources are implicitly supported for 'Last-Modified' and 'If-Modified-Since' headers.

The Controller

@Controller
public class TheController {

  public static long getResourceLastModified () {
      ZonedDateTime zdt = ZonedDateTime.of(LocalDateTime.of(2017, 1, 9,
                                                            10, 30, 20),
                                           ZoneId.of("GMT"));
      return zdt.toInstant().toEpochMilli();
  }

  @RequestMapping(value = "/test1")
  public String handle1 (ServletWebRequest swr) {

      //doesn't matter it returns false/true it will set the required headers automatically.
      //It doesn't include 'Cache-Control:no-cache' so have to do browser F5
      if (swr.checkNotModified(getResourceLastModified())) {
          //it will return 304 with empty body
          return null;
      }

      //uncomment the following if last-modified checking is needed at every action
     /* swr.getResponse().setHeader(HttpHeaders.CACHE_CONTROL,
                                  CacheControl.noCache()
                                              .getHeaderValue());*/

      return "myView";
  }

  @ResponseBody
  @RequestMapping(value = "/test2")
  public String handle2 (WebRequest swr) {

      if (swr.checkNotModified(getResourceLastModified())) {
          return null;
      }

      String testBody = "<p>Response time: " + LocalDateTime.now() +
                "</p><a href='test2'>test2</a>";

      return testBody;
  }

  @ResponseBody
  @RequestMapping(value = "/test3")
  public ResponseEntity<String> handle3 (WebRequest swr) {

      String testBody = "<p>Response time: " + LocalDateTime.now() +
                "</p><a href='test3'>test3</a>";

      //returning ResponseEntity with lastModified, HttpEntityMethodProcessor will
      //take care of populating/processing the required headers.
      //As the body can be replaced with empty one and 304 status can be send back,
      // this approach should be avoided if preparing the response body is very expensive.
      return ResponseEntity.ok()
                           .lastModified(getResourceLastModified())
                           .body(testBody);
  }
}

src/main/webapp/WEB-INF/pages/myView.jsp

<%@ page language="java"
    contentType="text/html; charset=ISO-8859-1"
    pageEncoding="ISO-8859-1"%>
<%@ page import="java.time.LocalDateTime"%>
<html>
  <body style="margin:20px;">
  JSP page
  <p>
  Date page created:<br/> <%= LocalDateTime.now()%>
  </p>
<a href='test1'>test1</a>
  </body>
</html>

src/main/webapp/static/static-test.html

<html>
<body>
This is a static page.
<br/>
<a href="">static-test.html</a>
</body>
</html>

As we are going to use Spring boot to run the example, we have to include the following properties:

src/main/resources/application.properties

spring.mvc.view.prefix= /WEB-INF/pages/
spring.mvc.view.suffix= .jsp

HeaderLogger filter

We are going to add a filter to log request/response headers. That will confirm the presence of the required headers. Instead of the Filter, we could have used Spring's HandlerInterceptor but that would not intercept our static page.

@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 () {
  }
}

The boot main class

@ServletComponentScan
@SpringBootApplication
public class Main {

  public static void main (String[] args) {
      SpringApplication sa = new SpringApplication(Main.class);
      sa.setLogStartupInfo(false);
      sa.setBannerMode(Banner.Mode.OFF);
      sa.run(args);
  }
}

Running application

mvn spring-boot:run

Output

/test1

Console output on the first access:

----- Request ---------
host: localhost:8080
user-agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:51.0) Gecko/20100101 Firefox/51.0
accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
accept-language: en-US,en;q=0.5
accept-encoding: gzip, deflate
connection: keep-alive
upgrade-insecure-requests: 1
pragma: no-cache
cache-control: no-cache
----- response ---------
Last-Modified: Sun, 08 Jan 2017 10:30:20 GMT
Set-Cookie: JSESSIONID=05E358254370EE12BE32E476FB2C5091;path=/;HttpOnly
response status: 200

Note that we will have the same output on Ctrl+F5 because that ignores the browser cache.


On hitting F5 key (or reload button), a request will be sent to the server but it will return 304 with empty body (unless the resource has been modified on server: in the controller's getResourceLastModified() method):

----- Request ---------
host: localhost:8080
user-agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:51.0) Gecko/20100101 Firefox/51.0
accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
accept-language: en-US,en;q=0.5
accept-encoding: gzip, deflate
cookie: JSESSIONID=05E358254370EE12BE32E476FB2C5091
connection: keep-alive
upgrade-insecure-requests: 1
if-modified-since: Sun, 08 Jan 2017 10:30:20 GMT
cache-control: max-age=0
----- response ---------
Last-Modified: Sun, 08 Jan 2017 10:30:20 GMT
response status: 304

The page will still show the same content.

Note that clicking on the link 'test1' will not send the request to the server, it will just reuse the browser cache directly. We can add 'no-cache' directive of 'Cache-Control' if we want it to behave like F5.

Now modify the date (increase to a future date) returned from our TheController#getResourceLastModified() and restart (if running in debug mode then we can hot-swap the changes without restarting). On hitting F5 key in the browser with same uri /test1:

----- Request ---------
host: localhost:8080
user-agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:51.0) Gecko/20100101 Firefox/51.0
accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
accept-language: en-US,en;q=0.5
accept-encoding: gzip, deflate
cookie: JSESSIONID=05E358254370EE12BE32E476FB2C5091
connection: keep-alive
upgrade-insecure-requests: 1
if-modified-since: Sun, 08 Jan 2017 10:30:20 GMT
cache-control: max-age=0
----- response ---------
Last-Modified: Mon, 09 Jan 2017 10:30:20 GMT
Set-Cookie: JSESSIONID=1F0F65DC38BA6CA9A14C11ECC9CF6028;path=/;HttpOnly
response status: 200

The date displayed on the page will also change.

/test2

The console output will be the similar on the same actions we used for /test1.


/test3

It will produce the same outcome as well.


/static/static-test.html

First access or Ctrl+F5

----- Request ---------
host: localhost:8080
connection: keep-alive
pragma: no-cache
cache-control: no-cache
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
accept-encoding: gzip, deflate, sdch, br
accept-language: en-US,en;q=0.8
cookie: JSESSIONID=986A4FF7A1DE357950BEEDF3B63DFF53
----- response ---------
Last-Modified: Thu, 02 Feb 2017 18:03:27 GMT
Accept-Ranges: bytes
Content-Type: text/html
Content-Length: 96
Date: Thu, 02 Feb 2017 20:06:46 GMT
response status: 200

On subsequent F5 (reload/refresh):

----- Request ---------
host: localhost:8080
user-agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:51.0) Gecko/20100101 Firefox/51.0
accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
accept-language: en-US,en;q=0.5
accept-encoding: gzip, deflate
cookie: JSESSIONID=1F0F65DC38BA6CA9A14C11ECC9CF6028
connection: keep-alive
upgrade-insecure-requests: 1
if-modified-since: Mon, 09 Jan 2017 10:30:20 GMT
cache-control: max-age=0
----- response ---------
Last-Modified: Mon, 09 Jan 2017 10:30:20 GMT
response status: 304

It has the same behavior as we saw in case of dynamic pages, which shows that the container provides implicit support of 'last-modified' header for static pages. This support is based on the last modified date of the File object.




Example Project

Dependencies and Technologies Used :

  • spring-boot-starter-web 1.4.4.RELEASE: Starter for building web, including RESTful, applications using Spring MVC. Uses Tomcat as the default embedded container.
    Corresponding Spring version: 4.3.6.RELEASE
  • tomcat-embed-jasper 8.5.11: Core Tomcat implementation.
  • JDK 1.8
  • Maven 3.3.9

Last Modified Example Select All Download
  • last-modified-example
    • src
      • main
        • java
          • com
            • logicbig
              • example
        • resources
        • webapp
          • WEB-INF
            • pages
          • static

See Also