5. HTTP Caching
Web pages often contain content that remains unchanged for long periods of time. For example, an image containing a company logo may be used without modification for many years. It is wasteful in terms of bandwidth and round trips to repeatedly download images, style sheets, scripts or other content that is not regularly updated.
HTTP supports caching so that content can be stored locally by the browser (and by shared caches such as proxies and CDNs) and reused when required. Of course, some types of data such as stock prices and weather forecasts are frequently changed and it is important that the browser does not display stale versions of these resources. By carefully controlling caching, it is possible to reuse static content and prevent the storage of dynamic or sensitive data.
Caching is mainly controlled by the cache-control response header, together with the last-modified and etag headers that let the browser check whether a cached copy is still valid. The rules are defined in RFC 9111 (HTTP Caching).
5.1 Preventing Caching
Servers set the cache-control response header to no-store to indicate that content must not be stored in any cache:
cache-control: no-store
This is the right choice for sensitive information such as bank statements. Note that the similarly named no-cache value does not prevent caching. It allows the content to be stored, but the cache must check with the server that the content is still valid every time it is used (see 5.3 below).
You may also see the pragma: no-cache or expires: -1 headers. These are only needed for very old HTTP/1.0 caches and are ignored when cache-control is present.
5.2 Allowing Caching
The cache-control header can contain one or more of the following values (known as directives) to allow caching:
max-age=<seconds> |
The content is fresh for the specified number of seconds after it was downloaded. While it is fresh, the cached copy can be used without contacting the server. |
private |
The content is intended for a single user and may only be cached by the browser, not by shared caches. |
public |
The content may be cached by shared caches (e.g. proxies and CDNs) as well as by the browser. |
s-maxage=<seconds> |
Overrides max-age for shared caches. |
immutable |
The content will never change while it is fresh, so the browser does not need to check it even if the user refreshes the page. |
stale-while-revalidate=<seconds> |
After the content has expired, the cache may carry on using it for the specified time while it fetches an updated copy in the background. |
For example, this header allows any cache to reuse the content for 31,536,000 seconds (one year) without contacting the server:
cache-control: public, max-age=31536000, immutable
An older way of setting an expiration date is the expires header, which specifies a date and time rather than a number of seconds. If both are present, max-age takes priority:
expires: Thu, 14 Oct 2027 12:00:00 GMT
If a response has no cache-control or expires header, browsers may still cache it and estimate how long it remains fresh, typically 10% of the time since the date in its last-modified header. This heuristic caching can lead to unexpected results, so it is better to always set cache-control.
Long expiration times greatly reduce the number of network round trips for frequently visited sites. But how can the content be updated if browsers will not check for a new version? Most sites solve this by including a version number or content hash in the file name, e.g. site.3f9a2c.css. When the file changes, its URL changes too, so the browser downloads the new version.
5.3 Cache Validation and the 304 Response
When a cached entry has expired, or was stored with no-cache, the browser must check with the server whether it can still be used. This is known as validation, and uses a conditional GET request. If the cached response had an etag (a server-assigned version identifier) the browser sends it in an if-none-match header. If it had a last-modified date, the browser sends it in an if-modified-since header. For example, this is the request Chrome sends when it validates its cached copy of Image B in Example 5 below:
:authority: www.httpwatch.com
:method: GET
:path: /httpgallery/caching/imageB/
:scheme: https
accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
if-none-match: "imageB-v1"
referer: https://www.httpwatch.com/httpgallery/caching/
The server checks these headers and responds accordingly. If the content has not changed, it replies with a status code of 304 and a response message that just contains headers:
:status: 304
cache-control: no-cache
date: Tue, 13 Oct 2026 11:03:21 GMT
etag: "imageB-v1"
server: Microsoft-IIS/10.0
x-powered-by: ASP.NET
Image C uses a last-modified date instead of an etag, so the request to validate it contains this header:
if-modified-since: Mon, 05 Jan 2026 12:00:00 GMT
The response can be quickly downloaded because it contains no content, and the browser reads the data it requires from its cache. In effect, it is like a redirection to the local browser cache (See 7. Redirection). The headers in the 304 response also update the cached entry, e.g. giving it a new expiration time.
If the content has actually changed, the server responds with a status code of 200 and supplies the modified version of the resource.
Example 5
The images in this example demonstrate different levels of caching. It is worth trying the following actions in your browser to investigate how well these images are cached:
- Refreshing the page, both straight away and after waiting more than a minute
- Using the back and forward buttons
- Opening this page in a new tab
- Forcing a full refresh with Ctrl+F5 or Ctrl+Shift+R
| Image A: | Uses cache-control: no-store. This image is never cached and is downloaded every time the page is displayed. | |
| Image B: |
|
Uses cache-control: no-cache and an etag. This image is cached, but the browser checks with the server each time it is used by sending its etag in an if-none-match header. The server replies with 304 Not Modified, so the image is not downloaded again. |
| Image C: |
|
Uses cache-control: max-age=60 and a last-modified date. For one minute after it is downloaded, this image is read from the cache without sending any request. After that it has expired, so the browser checks with the server using if-modified-since and receives a 304 response. |
| Image D: | Uses cache-control: public, max-age=31536000, immutable. This image can be read from the cache without sending any request to the server for a year, unless the cache is cleared or the user forces a full refresh with Ctrl+F5. |
Using HttpWatch with Example 5
To view the HTTP headers discussed on this page:
- Open HttpWatch by pressing Ctrl+Shift+H, clicking the HttpWatch icon on the browser toolbar, or right-clicking the page and selecting HttpWatch
- Click on Record to start logging requests in HttpWatch
- Optional: You can add a filter to capture only the images in Example 5 by adding a 'URL contains' condition with the value "caching/image".
- Try refreshing this page, using the back/forward buttons and opening the page in a new tab
- You can see the effect of a request on the browser cache by selecting the Cache tab. Also, the values of the cache-control, etag, last-modified, if-none-match and if-modified-since headers can be seen on the Headers tab.
- If an entry is read from the cache and no request is sent to the server, the Result column will show (Cache) or (Memory Cache) and no data will have been sent or received.
