In this tutorial we will learn Spring's support for the 'Last-Modified' and 'If-Modified-Since' headers. Please check out the general step-by-step usage of these headers. We also recommend checking out the related servlet tutorial to get a good understanding of working with these headers at a low level.
Spring provides the following convenient ways to set these headers.
Using WebRequest#checkNotModified()
The following method of WebRequest transparently checks the value of the 'If-Modified-Since' request header and sets the 'Last-Modified' value in the response header as needed.
boolean checkNotModified(long lastModifiedTimestamp);
We can have WebRequest, or its implementation ServletWebRequest, as a parameter of the @RequestMapping method.
Returning ResponseEntity<T> after setting the Last-Modified value
Using the following method of ResponseEntity.BodyBuilder (inherited from its parent interface, ResponseEntity.HeadersBuilder), we can set the response's 'Last-Modified' header.
B lastModified(long lastModified);
When the corresponding ResponseEntity is returned from the handler method, the required headers 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-Modified-Since' sent by the client is the same as the current modified date of the resource. Obviously this approach does not save on controller processing, since the full response must still be computed for each request, but it does save bandwidth by not sending the full response body to the client. If interested, check out the 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 dynamic content, and also how static resources implicitly support the 'Last-Modified' and 'If-Modified-Since' headers.
Resource Service
package com.logicbig.example;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
@Service
public class ResourceService implements DisposableBean {
private Path resourceFile;
public ResourceService() throws Exception {
File tmp = File.createTempFile("demo-resource-", ".txt");
this.resourceFile = tmp.toPath();
Files.write(resourceFile, "test data".getBytes(StandardCharsets.UTF_8));
}
public String read() throws IOException {
String data = new String(Files.readAllBytes(resourceFile));
return data + ", read at " +
LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS);
}
public long lastModified() throws IOException {
return Files.getLastModifiedTime(resourceFile).toMillis();
}
public void update(String content) throws IOException {
Files.write(resourceFile, content.getBytes(StandardCharsets.UTF_8));
}
@Override
public void destroy() throws Exception {
Files.deleteIfExists(resourceFile);
}
}
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 java.io.IOException;
@Controller
public class TheController {
@Autowired
private ResourceService resourceService;
@ResponseBody
@RequestMapping(value = "/test1")
public String handle1(ServletWebRequest swr) throws IOException {
//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(resourceService.lastModified())) {
//it will return 304 with empty body
return null;
}
//uncomment the following if last-modified checking is needed at every action
return resourceService.read();
}
@ResponseBody
@RequestMapping(value = "/test2")
public ResponseEntity<String> handle2() throws IOException {
//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(resourceService.lastModified())
.body(resourceService.read());
}
@ResponseBody
@RequestMapping(value = "/update", method = RequestMethod.POST)
public String updateResource(@RequestBody String content) throws IOException {
resourceService.update(content);
return "Resource updated";
}
}
A Filter to print information
We are going to add a filter to log the request/response headers. That will confirm the presence of the required headers. Instead of a 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) {
}
@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 Config Class
@ComponentScan
@EnableWebMvc
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("/static/")
.setCachePeriod(30);
}
}
src/main/webapp/static/static-test.html<html>
<body>
This is a static page.
<br/>
<a href="">static-test.html</a>
</body>
</html>
To try examples, run embedded Jetty (configured in pom.xml of example project below):
mvn jetty:run
Example Outputs
/test1
 $ curl -s -o page.html -z page.html "http://localhost:8080/test1" && type page.html test data, read at 2026-09-20T00:47:20.371
Server Output ----- Request --------- Accept: */* User-Agent: curl/8.21.0 Host: localhost:8080 ----- response --------- Last-Modified: Sun, 20 Sep 2026 05:47:13 GMT Content-Length: 42 Date: Sun, 20 Sep 2026 05:47:20 GMT Content-Type: text/plain;charset=iso-8859-1 response status: 200
In the above curl command, we used the -o flag to save the downloaded content to a specific filename and the -z flag to only download the file if the online version is newer than the local copy (a functionality that is already built into modern web browsers).
Accessing 'list1' again , a request will be sent to the server, but it will return 304 with an empty body (unless the resource has been modified on the server as the value returned the controller's getResourceLastModified() method):
 $ curl -s -o page.html -z page.html "http://localhost:8080/test1" && type page.html test data, read at 2026-09-20T00:47:20.371
Server Output ----- Request --------- Accept: */* User-Agent: curl/8.21.0 If-Modified-Since: Sun, 20 Sep 2026 05:47:20 GMT Host: localhost:8080 ----- response --------- Last-Modified: Sun, 20 Sep 2026 05:47:13 GMT Date: Sun, 20 Sep 2026 05:47:38 GMT response status: 304
Modify Resource
Let's call /update to modify the content which will modify last modified date/time as well.
$ curl -s -X POST "http://localhost:8080/update" -H "Content-Type: text/plain;charset=UTF-8" --data "updated test data" Resource updated
Now access /test1 again
 $ curl -s -o page.html -z page.html "http://localhost:8080/test1" && type page.html updated test data, read at 2026-09-20T00:48:02.806
Server Output ----- Request --------- Accept: */* User-Agent: curl/8.21.0 If-Modified-Since: Sun, 20 Sep 2026 05:47:20 GMT Host: localhost:8080 ----- response --------- Last-Modified: Sun, 20 Sep 2026 05:47:51 GMT Content-Length: 50 Date: Sun, 20 Sep 2026 05:48:02 GMT Content-Type: text/plain;charset=iso-8859-1 response status: 200
This time it returned new content with status code 200
Access /test1 one more time
 $ curl -s -o page.html -z page.html "http://localhost:8080/test1" && type page.html && del page.html updated test data, read at 2026-09-20T00:48:02.806
Server Output ----- Request --------- Accept: */* User-Agent: curl/8.21.0 If-Modified-Since: Sun, 20 Sep 2026 05:48:02 GMT Host: localhost:8080 ----- response --------- Last-Modified: Sun, 20 Sep 2026 05:47:51 GMT Date: Sun, 20 Sep 2026 05:48:10 GMT response status: 304
Now we have the same content but with status 304, so that means the content were used from the local stored file.
/test2
This endpoint demonstrates the use of ResponseEntity with lastModified().
@Controller
public class TheController {
@Autowired
private ResourceService resourceService;
.............
@ResponseBody
@RequestMapping(value = "/test2")
public ResponseEntity<String> handle2() throws IOException {
return ResponseEntity.ok()
.lastModified(resourceService.lastModified())
.body(resourceService.read());
}
.............
}
 $ curl -s -o page2.html -z page2.html "http://localhost:8080/test2" && type page2.html updated test data, read at 2026-09-20T00:49:12.128
Server Output ----- Request --------- Accept: */* User-Agent: curl/8.21.0 Host: localhost:8080 ----- response --------- Last-Modified: Sun, 20 Sep 2026 05:47:51 GMT Content-Length: 50 Date: Sun, 20 Sep 2026 05:49:12 GMT Content-Type: text/plain;charset=iso-8859-1 response status: 200
Accessing /test2 one more time:
 $ curl -s -o page2.html -z page2.html "http://localhost:8080/test2" && type page2.html && del page2.html updated test data, read at 2026-09-20T00:49:12.128
Server Output ----- Request --------- Accept: */* User-Agent: curl/8.21.0 If-Modified-Since: Sun, 20 Sep 2026 05:49:12 GMT Host: localhost:8080 ----- response --------- Last-Modified: Sun, 20 Sep 2026 05:47:51 GMT Date: Sun, 20 Sep 2026 05:49:17 GMT response status: 304
/static/static-test.html
 $ curl -s -o page3.html -z page3.html "http://localhost:8080/static/static-test.html" && type page3.html <html> <body> This is a static page. <br/> <a href="">static-test.html</a> </body> </html>
Server Output ----- Request --------- Accept: */* User-Agent: curl/8.21.0 Host: localhost:8080 ----- response --------- Accept-Ranges: bytes Cache-Control: max-age=30 Last-Modified: Thu, 02 Feb 2017 04:03:27 GMT Content-Length: 96 Date: Sun, 20 Sep 2026 05:49:27 GMT Content-Type: text/html response status: 200
Accessing the static page again:
 $ curl -s -o page3.html -z page3.html "http://localhost:8080/static/static-test.html" && type page3.html && del page3.html <html> <body> This is a static page. <br/> <a href="">static-test.html</a> </body> </html>
Server Output ----- Request --------- Accept: */* User-Agent: curl/8.21.0 If-Modified-Since: Sun, 20 Sep 2026 05:49:27 GMT Host: localhost:8080 ----- response --------- Last-Modified: Thu, 02 Feb 2017 04:03:27 GMT Date: Sun, 20 Sep 2026 05:49:33 GMT response status: 304
This is the same behavior we saw in the case of dynamic pages, which shows that the container provides implicit support for the 'Last-Modified' header for static pages. This support is based on the last-modified date of the File object.
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
|