HTTP Cache Validation

HTTP Cache Validation

When a resource needs to be updated as soon as a change is made to the underlying data, the expiration model falls short. With the expiration model, the application won’t be asked to return the updated response until the cache finally becomes stale.

The validation model addresses this issue. Under this model, the cache continues to store responses. The difference is that, for each request, the cache asks the application if the cached response is still valid or if it needs to be regenerated. If the cache is still valid, your application should return a 304 status code and no content. This tells the cache that it’s OK to return the cached response.

Under this model, you only save CPU if you’re able to determine that the cached response is still valid by doing less work than generating the whole page again (see below for an implementation example).

Tip

The 304 status code means “Not Modified”. It’s important because with this status code the response does not contain the actual content being requested. Instead, the response only consists of the response headers that tells the cache that it can use its stored version of the content.

Like with expiration, there are two different HTTP headers that can be used to implement the validation model: ETag and Last-Modified.

Expiration and Validation

You can use both validation and expiration within the same Response. As expiration wins over validation, you can benefit from the best of both worlds. In other words, by using both expiration and validation, you can instruct the cache to serve the cached content, while checking back at some interval (the expiration) to verify that the content is still valid.

Tip

You can also define HTTP caching headers for expiration and validation by using annotations. See the FrameworkExtraBundle documentation.

Validation with the ETag Header

The HTTP ETag (“entity-tag”) header is an optional HTTP header whose value is an arbitrary string that uniquely identifies one representation of the target resource. It’s entirely generated and set by your application so that you can tell, for example, if the /about resource that’s stored by the cache is up-to-date with what your application would return.

An ETag is like a fingerprint and is used to quickly compare if two different versions of a resource are equivalent. Like fingerprints, each ETag must be unique across all representations of the same resource.

To see a short implementation, generate the ETag as the md5 of the content:

  1. // src/Controller/DefaultController.php
  2. namespace App\Controller;
  3. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  4. use Symfony\Component\HttpFoundation\Request;
  5. class DefaultController extends AbstractController
  6. {
  7. public function homepage(Request $request)
  8. {
  9. $response = $this->render('static/homepage.html.twig');
  10. $response->setEtag(md5($response->getContent()));
  11. $response->setPublic(); // make sure the response is public/cacheable
  12. $response->isNotModified($request);
  13. return $response;
  14. }
  15. }

The [isNotModified()](https://github.com/symfony/symfony/blob/4.4/src/Symfony/Component/HttpFoundation/Response.php "Symfony\Component\HttpFoundation\Response::isNotModified()") method compares the If-None-Match header with the ETag response header. If the two match, the method automatically sets the Response status code to 304.

Note

When using mod_deflate or mod_brotli in Apache 2.4, the original ETag value is modified (e.g. if ETag was foo, Apache turns it into foo-gzip or foo-br), which breaks the ETag-based validation.

You can control this behavior with the DeflateAlterETag and BrotliAlterETag directives. Alternatively, you can use the following Apache configuration to keep both the original ETag and the modified one when compressing responses:

  1. RequestHeader edit "If-None-Match" '^"((.*)-(gzip|br))"$' '"$1", "$2"'

Note

The cache sets the If-None-Match header on the request to the ETag of the original cached response before sending the request back to the app. This is how the cache and server communicate with each other and decide whether or not the resource has been updated since it was cached.

This algorithm works and is very generic, but you need to create the whole Response before being able to compute the ETag, which is sub-optimal. In other words, it saves on bandwidth, but not CPU cycles.

In the Optimizing your Code with Validation section, you’ll see how validation can be used more intelligently to determine the validity of a cache without doing so much work.

Tip

Symfony also supports weak ETag s by passing true as the second argument to the [setEtag()](https://github.com/symfony/symfony/blob/4.4/src/Symfony/Component/HttpFoundation/Response.php "Symfony\Component\HttpFoundation\Response::setEtag()") method.

Validation with the Last-Modified Header

The Last-Modified header is the second form of validation. According to the HTTP specification, “The Last-Modified header field indicates the date and time at which the origin server believes the representation was last modified.” In other words, the application decides whether or not the cached content has been updated based on whether or not it’s been updated since the response was cached.

For instance, you can use the latest update date for all the objects needed to compute the resource representation as the value for the Last-Modified header value:

  1. // src/Controller/ArticleController.php
  2. namespace App\Controller;
  3. // ...
  4. use App\Entity\Article;
  5. use Symfony\Component\HttpFoundation\Request;
  6. use Symfony\Component\HttpFoundation\Response;
  7. class ArticleController extends AbstractController
  8. {
  9. public function show(Article $article, Request $request)
  10. {
  11. $author = $article->getAuthor();
  12. $articleDate = new \DateTime($article->getUpdatedAt());
  13. $authorDate = new \DateTime($author->getUpdatedAt());
  14. $date = $authorDate > $articleDate ? $authorDate : $articleDate;
  15. $response = new Response();
  16. $response->setLastModified($date);
  17. // Set response as public. Otherwise it will be private by default.
  18. $response->setPublic();
  19. if ($response->isNotModified($request)) {
  20. return $response;
  21. }
  22. // ... do more work to populate the response with the full content
  23. return $response;
  24. }
  25. }

The [isNotModified()](https://github.com/symfony/symfony/blob/4.4/src/Symfony/Component/HttpFoundation/Response.php "Symfony\Component\HttpFoundation\Response::isNotModified()") method compares the If-Modified-Since header with the Last-Modified response header. If they are equivalent, the Response will be set to a 304 status code.

Note

The cache sets the If-Modified-Since header on the request to the Last-Modified of the original cached response before sending the request back to the app. This is how the cache and server communicate with each other and decide whether or not the resource has been updated since it was cached.

Optimizing your Code with Validation

The main goal of any caching strategy is to lighten the load on the application. Put another way, the less you do in your application to return a 304 response, the better. The Response::isNotModified() method does exactly that:

  1. // src/Controller/ArticleController.php
  2. namespace App\Controller;
  3. // ...
  4. use Symfony\Component\HttpFoundation\Request;
  5. use Symfony\Component\HttpFoundation\Response;
  6. class ArticleController extends AbstractController
  7. {
  8. public function show($articleSlug, Request $request)
  9. {
  10. // Get the minimum information to compute
  11. // the ETag or the Last-Modified value
  12. // (based on the Request, data is retrieved from
  13. // a database or a key-value store for instance)
  14. $article = ...;
  15. // create a Response with an ETag and/or a Last-Modified header
  16. $response = new Response();
  17. $response->setEtag($article->computeETag());
  18. $response->setLastModified($article->getPublishedAt());
  19. // Set response as public. Otherwise it will be private by default.
  20. $response->setPublic();
  21. // Check that the Response is not modified for the given Request
  22. if ($response->isNotModified($request)) {
  23. // return the 304 Response immediately
  24. return $response;
  25. }
  26. // do more work here - like retrieving more data
  27. $comments = ...;
  28. // or render a template with the $response you've already started
  29. return $this->render('article/show.html.twig', [
  30. 'article' => $article,
  31. 'comments' => $comments,
  32. ], $response);
  33. }
  34. }

When the Response is not modified, the isNotModified() automatically sets the response status code to 304, removes the content, and removes some headers that must not be present for 304 responses (see [setNotModified()](https://github.com/symfony/symfony/blob/4.4/src/Symfony/Component/HttpFoundation/Response.php "Symfony\Component\HttpFoundation\Response::setNotModified()")).

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.