How Spring’s Built-in Support Simplifies Http Caching
Using HTTP caching results in avoiding re-fetching the same data, improving performance & response times.
There are three approaches to this:
Cache-Control Defines how, and for how long, a resource should be cached.
Last-Modified Allows clients to validate if the resource has changed since the last fetch using a timestamp.
ETag Uses a unique identifier (a hash or version) to determine if the resource has changed.
Certain API's aren't expected to change frequently but some may be more consistent than others. They are used for serving different levels of consistency.
Cache Control
Let’s take countries as an example. This would be one of the most consistent APIs compared to others in terms of changing.
What cache-control helps us with is specifying the amount of time that the client would cache this response:
return ResponseEntity.ok()
.cacheControl(
CacheControl.maxAge(1, TimeUnit.HOURS)
)
.body(countries);
Spring offers the org.springframework.http.CacheControl for configuring different instructions to this header, such as maxAge, etc..
An example of the response would be:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=3600
"USA","Canada","Germany"
The client receives and must make use of this header, so in turn it doesn't need to send a request again as specified by the time amount.
The handling (making use of and checking if expired) happens automatically and supported for most browsers, so that means even if you are building apps through lets say -Angular, its HttpClient doesn't need configs.
Last-Modified & ETag
As both conditional approaches, they need server-side handling for checking if there are any changes in the resource.
But there is a disadvantage when comparing the Last-Modified to the ETag. HTTP timestamps (used in Last-Modified) are only precise to the second, It records hours:minutes:seconds, but not milliseconds: Tue, 08 Apr 2025 16:30:00 GMT.
@GetMapping("/{id}")
public ResponseEntity<Article> getArticle(
@PathVariable Long id,
@RequestHeader(value = "If-Modified-Since", required = false) String ifModifiedSince
) {
// Parse client header if present
if (ifModifiedSince != null) {
Instant clientTime = Utils.parseRFC1123ToInstant(ifModifiedSince);
if (!article.getLastModified().isAfter(clientTime)) {
// Not modified
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
}
}
return ResponseEntity.ok()
.lastModified(
article.getLastModified().toEpochMilli()
)
.body(article);
}
@PutMapping("/{id}")
public ResponseEntity<Void> updateBlogPost(
@RequestHeader(value = "If-Unmodified-Since", required = false) String ifUnmodifiedSince,
@PathVariable Long id,
@RequestBody Article updated) {
Instant currentLastModified = article.getLastModified();
if (ifUnmodifiedSince != null) {
Instant clientTime = Utils.parseRFC1123ToInstant(ifUnmodifiedSince);
if (currentLastModified.isAfter(clientTime)) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
}
// Update allowed
article.setContent(updated.getContent());
article.setLastModified(Instant.now()); // Refresh lastModified time
return ResponseEntity.ok()
.lastModified(article.getLastModified().toEpochMilli())
.build();
}
The ETag doesn't have the same problem, through the ETag we can have a byte-level precision:
@GetMapping("/{id}")
public ResponseEntity<BlogPost> getBlogPost(
@PathVariable Long id,
@RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch
) {
String currentETag = "\"" + Utils.sha256Hex(
blogPost.getTitle() + blogPost.getContent()
) + "\"";
if (ifNoneMatch != null && ifNoneMatch.equals(currentETag)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
.eTag(currentETag)
.build();
}
return ResponseEntity.ok()
.eTag(currentETag)
.body(blogPost);
}
@PutMapping("/{id}/edit")
public ResponseEntity<Void> updateBlogPost(
@RequestHeader(value = "If-Match", required = false) String ifMatch,
@PathVariable Long id,
@RequestBody BlogPost updated) {
// Generate the current ETag based on existing content
String currentETag = "\"" + Utils.sha256Hex(blogPost.getTitle() + blogPost.getContent()) + "\"";
// If client sends If-Match, check it against current ETag
if (ifMatch != null && !ifMatch.equals(currentETag)) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
blogPost = new BlogPost(
id,
updated.getTitle(),
updated.getContent()
);
String newETag = "\"" + Utils.sha256Hex(blogPost.getTitle() + blogPost.getContent()) + "\"";
return ResponseEntity.ok()
.eTag(newETag)
.build();
}