DevelopmentDecember 6, 2025

Cache control best practices for your website

You may have noticed that sometimes, when you visit a website for the second time, it doesn't look the way it should: some styles are broken and everything looks a bit off. Usually, the cause is poorly defined cache control policies that prevent the changes from the latest deployment from getting through. In this article I'll show you the right cache configuration and the techniques that will help every user see the up-to-date version of your website after each deployment.

If you just want the best practices to start applying right away, jump straight to the end of the article.

How does cache work under the hood?

On every request to a website or resource, your browser tries to download as little data as possible by reading the information stored in the local cache. This is only possible if we give the browser enough instructions about which resources to store and for how long.

These instructions work as directives; to communicate them to the browser, they need to be added to the response's HTTP headers. The most common directives in the caching process are "Cache-Control", "Expires", "Etag", and "Last-Modified".

Almost every web server includes some default cache setting in the response headers, but it isn't so clear what happens if there's no cache policy at all.

Without a cache control configuration, the browser hits the web server on every request for resources and reads the information from there. This increases the site's load time, adds extra load to the server from transferring the information, and increases the number of calls to your backend.

Request flow without cache configuration
Request flow without cache configuration

Without instructions from the server, it's the browser that decides what to do and how to cache the information. Right now, Chrome and Safari download data from the backend every time if there are no cache instructions. But this could change and lead to different behavior, especially on other platforms.

To clearly define what to do with each type of file, let's dig into the cache control directives, adding them step by step to the response headers and observing the result.

Etag (Entity tag)

Etag is one of the cache settings. The main idea behind this HTTP header is for the browser to know whether a resource has changed without having to download the whole file. The server can compute something like a hash of each file and send it to the client. The next time the client wants to access that resource, instead of downloading the file, the browser will send something like this in the HTTP header: If-None-Match: W/"1d2e7–1648e509289". The server will compare that hash with the current file's hash and, if they differ, force the client to download the new file. If not, it will tell it to use the cached version.

Request flow with Etag: first load
Request flow with Etag: first load
Request flow with Etag: second load
Request flow with Etag: second load

With an Etag-based cache policy, the server is always consulted to check the file's hash, and only then does the browser decide whether to take it from cache or download it in full. When a resource hasn't changed, the check takes up just 80–100 bytes, regardless of whether you're requesting a 10 KB or a 10 MB file.

Last Modified

Another cache control setting is the "Last Modified" HTTP header. The idea is very similar to Etag's, but the browser's behavior is somewhat different. Servers store the last modification date of each file; after the first load, the client can ask the server whether the resource has changed since the exact moment it last accessed it. To do this, the browser sends If-Modified-Since: Fri, 13 Jul 2018 10:49:23 GMT in the HTTP header. If the resource has changed, the browser has to download the new file; if not, it uses the cached version.

The truth is that browsers have their own internal cache policies and can decide on their own whether to take a resource from cache or download a fresh copy.

Last-Modified is a weak cache header, since the browser applies a heuristic to decide whether to take the item from cache or not, and that heuristic varies from browser to browser.

Google's cache best practices guide
Request flow with Last-Modified: first load
Request flow with Last-Modified: first load
Request flow with Last-Modified: second load (ideal scenario)
Request flow with Last-Modified: second load (ideal scenario)
Request flow with Last-Modified: second load (typical case)
Request flow with Last-Modified: second load (typical case)

As a result, we can't rely solely on Last-Modified, so I prefer to remove it entirely from the server configuration to reduce traffic, even if it's only a few bytes.

Cache-Control max-age

This directive lets you tell the browser how long to keep a file in cache from the first load. That time is defined in seconds and is usually expressed like this: Cache-Control: max-age=31536000. With this policy, the browser skips server requests entirely and opens files very fast. But how can we be sure the file won't change in all that time? We can't.

That's why, to force the browser to download the new version of a file, we use a technique implemented by many asset build tools, such as Webpack or Gulp. Each file is precompiled on the server and a hash is added to its name, for example "app-72420c47cc.css." Even the smallest change to the file is reflected in the hash, which guarantees it's recognized as a different file. That way, after the next deployment, you'll get the new version of the file. This can be applied to all css, js, and image files (max-age=31536000): whenever we change something, the browser simply requests the new file with its new hash and stores it in cache.

no-cache

The tricky part of the previous technique is that you can't forget about html files: if you apply that same setting to them too, you'll never get the new links to your css, js, or image files until you force a reload.

I recommend applying Cache-Control: no-cache to html files. Using "no-cache" doesn't mean there's no caching at all; it simply tells the browser to validate resources with the server before using them from cache. That's why it needs to be combined with Etag, so the browser sends a simple request and loads those extra 80 bytes to check the file's status.

Final configuration

  • Use Gulp, Webpack, or a similar tool to add a unique hash to your css, js, and image files (like app-67ce7f3483.css).
  • For js, css, and image files, set Cache-Control: public, max-age=31536000, without Etag or Last-Modified.
  • For html files, use Cache-Control: no-cache and Etag.

As you can see, even something as obvious and common as caching static files stops being obvious once you dig deeper. Researching it properly will help you avoid mistakes, reduce your server's traffic, and improve your website's load times.

Tell us about your case

Get in touch