HTTP-304 Conditional Cache Control using Spring MVC

Simar Paul Singh
Simar's blog
Published in
8 min readAug 5, 2018

Increasing Performance with HTTP Cache Headers while ensuring the fresh data served always

Performance

  • Built — Algorithms / Big 0 | Linear vs Binary Search
  • Fueled — Compute, Memory, Disk | Single vs Multi Core …
  • Tuned — GC Tuning, VM Args Heap -Xmx -Xms Surivor Ratio etc

Sometimes we and get it by Default

Don’t just turn off the cache. Let browser handle the HTTP Request cache-control Headers

GET /welcome/ HTTP/1.1
If-Modified-Since: Mon, 26 Jul 1997 05:00:00 GMT
Cache-Control: no-cache
Pragma: no-cache

Who is turning OFF the HTTP cache? Most often it’s not Client side

  • Most often Users don’t know about caching or even HTTP. They are mostly using their Web Browser as a black-box
  • Sometimes, users can leave Caching disabled ex. in Chrome Dev Tools.
  • Rarely, Developer without any need is disabling the HTTP Cache client side code or script
GET /welcome/ HTTP/1.1
If-Modified-Since: Mon, 26 Jul 1997 05:00:00 GMT
Cache-Control: no-cache no-store
Pragma: no-cache

Server Side is likely turning HTTP Caching OFF. But How and Why?

Most of web servers and CDNs are sophisticated enough to have right cache control in place for static-content (images, html, js etc.) and is usually based on ETags (content-hash) or last-modified strategy.

However, when it comes dynamic content and / or REST APIs backed MVC frameworks modeled over data-stores and business logic, we are responsible for setting proper Cache-Control headers programatically.

ETags can be easily setup as a cross-cutting concern as it just works on the content payload being delivered, hashing it to find out if the ETag: "<some-hash> client sent in HTTP request header of its cached-content is same as the content being delivered from service controller. If so, server responds with HTTP-304 Not-Modified (no-content). Learn more about ETags here.

We will discuss last-modified and ETag in more detail when they aren’t a cross-cutting concern, and most often last-modified needs to be determined programmatically in context to the called resource.

If we can get the last-modified timestamp of resource needed to build our response-payload (Model needed to build our DTO) without retrieving the resource from data-store, we can compare it to if-modified-since: <some-data-time> from HTTP request headers and respond with HTTP-304 Not-Modified (no-content) if the resource hasn’t been modified since, without even fetching the the resource from data-store.

Similarly, ETag could sometimes be simply a function(session-id, user-principal where we are just looking to discriminate cache across user and/or sessions. Essentially, when cached content is valid per session / login.

All good so far, then who exactly is the show stopper here?

By default, Spring Security sets specific cache control header values for us, without us having to configure anything such that browser will never cache HTTP responses

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {...}
@GetMapping("/default/users/{name}")
public ResponseEntity getUserWithDefaultCaching(@PathVariable String name) {
return ResponseEntity.ok(new UserDto(name));
}
The resulting cache-control header will look like this:
[cache-control: no-cache, no-store, max-age=0, must-revalidate]

Though inefficient, there is actually a good reason for this default behavior — If one user logs out and another one logs in, we don’t want them to be able to see the previous users resources. It’s much safer to not cache anything by default, and leave us to be responsible for enabling caching explicitly. More…

Before we learn how to set up Condition HTTP-304 Not Modified Cache headers int he last section, let’s learn a bit more about all the HTTP Cache-Control options at our disposal.

Note: Sever sets Cache-Control strategies in response headers, user-agent (client/browsers) by default participate in the Cache-Control by caching and including ETag: "<hash-of-last-cached>" and/or if-modified-since: <last-modified-time-of-last-cached , however if the user-agent (browser) does not include these headers in the request (for reasons discussed in the section above), request-response flow will all work as if no caching was there

Cache Coherency with If-modified-since: date

GET /api/auth HTTP/1.1HTTP/1.1 200 OK
Cache-Control: no-cache no-store
Pragma: no-cache
GET /api/welcome.json HTTP/1.1
If-Modified-Since: Mon, 29 Jun 2018 02:28:12 GMT
HTTP/1.1 304 Not Modified
Cache-Control: private max-age=0, must-revalidate
Content-Type: text/json
GET /asset/welcome.html HTTP/1.1HTTP/1.1 200 OK
Cache-Control: public max-age=3600, must-revalidate
Expires: Fri, 30 Oct 1998 14:19:41 GMT
Last-Modified: Mon, 29 Jun 1998 02:28:12 GMT
ETag: "3e86-410-3596fbbc"
Content-Type: text/html

More…

How to make Browsers Cache response but ensure Browsers must re-validate before every use?

HTTP 200 future caching allows browser to use the cached content with in specified `max-age: x seconds` without checking with server if the content has changed.

Trick is to set `max-age: 0`; it forces Browser to validate its cached content with the server’s version by sending the cached content’s Last-Modified or ETag in request headers of HTTP GET and only use cached content if server responds with `HTTP-304 Not modified`

max-age=0 in Cache-Control header froces browser to recheck your cached-response with the server.must-revalidate in Cache-Control header says that the cache may not serve this content when it is stale (i.e. "expired"), but must revalidate before that. Yes, caches (and browsers) can in theory be set to serve pages even if they are stale, though the standard says they should warn the user if they do this.no-cache used to be that you could add instead, but as users have been expecting this to behave as no-store, browsers are gradually treating them the same.

More…

Surprise….Yes it works in IE 6+

More…

Spring MVC makes Conditional 304 HTTP Caching with response headers easy

Spring MVC supports two ways of controlling HTTP responses with Cache Headers.

@Controller  
public class TheController {

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

if ( swr.checkNotModified( getResourceLastModified(swr), getETag(swr) ) ) {
//it will return 304 with empty body
return notModified(swr);
// returning null would have worked but spring-security resets cache headers, return notModified(swr)
}

return response("<p>Last modified epoch: " + getResourceLastModified(swr) +
"</p><a href='test2'>test2</a>", swr);
}

private <T,R> ResponseEntity<R> response(T entity, WebRequest swr) {
/*If 'Cache-Control:max-age: x' where x >0, we have to do hard refresh to see the changes until browser expires the cache in x seconds. max-age:0 solves this problem by making browsers always re-validate with server before using the cache*/

return ofNullable(entity)
.map(e -> ok()
.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS).cachePrivate().mustRevalidate())
.eTag(getETag(swr))
.lastModified(getResourceLastModified(swr))
.body(e)
)
.orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
}

private <T> ResponseEntity<T> notModified(WebRequest swr) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS).cachePrivate().mustRevalidate())
.lastModified(getResourceLastModified(swr))
.eTag(getETag(swr))
.build();
}

private String getEtag(WebRequest swr) { ... // you can use sessionId swr.resolveReference("session") }

private long getResourceLastModified(WebRequest swr) { // access api request params, to determine last-modified}

@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()
.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS).cachePrivate().mustRevalidate())
.eTag(getETag(swr))
.lastModified(getResourceLastModified(swr))
.body(testBody)
}
}

Same mechanisms work for views (returning template identifiers instead of response body)

@Controller
public class TheController {

public static long getResourceLastModified () {}

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

//doesn't matter it returns false/true it will set the required headers automatically.
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.maxAge(0, TimeUnit.SECONDS)
.getHeaderValue());*/

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

return "myView";
}
}

Cache Security and Cross User Privacy in mind

  • `Cache-Control: private` just means don’t cache at proxy / web-servers, only user-agent (browser) can cache the content
  • `Cache-Control: public` means proxy / web-servers, intermediaries can cache and relay responses to user-agent without going to server until `max-age: x` x seconds expires the cached content
private vs public caching

More…

So for private / contextual user based content, how do we ensure we always go to the service end-point, apply permissions, filters etc tied to the user in context before allowing browser to use the cache?

Set HTTP response Cache-Control: private, max-age=0, must-revalidate,last-modified:<GMT ISO DATE_TIME> in response. This tells browser to cache but with max-age:0 ; meaning always go to the server with HTTP request Cache-Control: if-modified-since:<last-modified> and expect server to decide using the received session token and if-modified-since header and respond with:

  1. Either `HTTP-304 (no-content)` as an positive indication to use cache, if the if-modified-since received in the client’s request is same as same or greater than last-modified time of the resource server expected to serve
  2. Or if content has since changed, server responds withHTTP response Cache-Control: private, max-age=0, must-revalidate,last-modified:<GMT ISO DATE_TIME with new timestamp>
  3. Or, error Ex.HTTP-401 Unauthorized, 403 Forbidden if user is not allowed.
void applyPermission(String userId, HttpSession session) {  
if(getUser(session).id != userId) {
throw new SecurityException("Not Allowed")
}
// ExceptionMapper can map Security Exception to HTTP-401 Unauthorized or HTTP-403 Forbidden

}

@ResponseBody
@RequestMapping(value = "/user/{userId}/order/{orderId}")
public ResponseEntity<String> handle3 (@PathVariable String userId, @PathVariable orderId, HttpSession session, WebRequest swr) {

// ensure the user is permitted session even before fetching the order
this.applyPermissions(userId, session, orderId); // permissions are not cross-cutting (differ per target)
final Order order = this.orderRepository.findByUserAndOrder(userId, orderId);

if (swr.checkNotModified(order.getLastModified())) {
//it will return 304 with empty body
return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS).cachePrivate().mustRevalidate())
.build();
// returning null would have worked but spring-security resets cache headers
}
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS).cachePrivate().mustRevalidate())
.lastModified(order.getLastModified())
.body(testBody);
}
}

Why is HTTP Response Caching Important?

  • Improve User Experience with Incresed Speed and Responsive
  • Save Cost over unecessary Data Transfer
  • Save Hardware Resources | (Compute, Memory and Disk/DB Access)
  • We cannot control how our clients will access our applications over the Internet
  • Many of Cloud Native Solutions are sharing resources. Pay per use
  • ISPs are Monetizing over Usage not just connectivity
Governed by HTTP standard; Browser / Agents have gone through decades of evolution to make it work by default

MDN | HTTP Conditional GetHTTP/1.1 RFC, section 14.9 Spring HTTP ETagsSpring HTTP Last Modified

If you liked this article please click the ❤ below or (clap) on the side. It’ll motivate me to write more articles like this, and it’ll help other people discover the article as well.

--

--