Contact Us

Client-Side Caching in Angie

One of the simplest ways to speed up websites and cut load on the infrastructure is to use client-side caching correctly. The mechanism is both simple and complicated at the same time. This article looks at how to manage client-side caching in the Angie web server.

Video version

A video version of this article is available on YouTube.

Caching mechanism

On the first visit to a typical modern site, the browser first has to establish a connection with the server, usually over HTTPS, to request and receive the HTML document. Then it has to parse that document and schedule dozens of requests for additional objects: images, stylesheets, JS files, web fonts, and so on. As a result, loading a single typical page produces around 100 requests and transfers several megabytes.

The user then moves to another page of the site, which holds roughly 100 more objects, so the whole process has to repeat. A closer look shows that more than half of those objects have already been downloaded by the browser and do not need to be fetched again on navigation. That is exactly the job client-side caching does.

Static assets — requests for files on the server's disk — are usually cached most aggressively. These are images, fonts, CSS, JS and similar elements. They rarely change and often make up most of a page's traffic. Dynamic documents (HTML, JSON, and so on) are usually not cached, and may even explicitly forbid being stored in a cache. There are exceptions, though, where a dynamic document is carefully cached for a short time.

A cache needs two sides: a client and a server. The server's task is to set headers in its response that permit or forbid caching. Keep in mind that even with no caching headers present, a client may cache content to reduce the number of requests, following heuristic rules. To explicitly turn caching off, use the Cache-Control header.

Consider the following example:

Cache-Control: public, max-age=3600
Etag: "04bd84392596ea1979453727b666caa9"
Expires: Fri, 22 Aug 2025 13:46:37 GMT
Last-Modified: Tue, 19 Aug 2025 17:49:13 GMT

Every caching header variant is used at once here. Having received such headers, the client can use the cache to store the object. Some of them also help search engine crawlers work efficiently by cutting unnecessary requests to the server. We will go through how each of these headers works below.

Caching can happen not only on the client but also on intermediate servers (proxies, CDN nodes, and so on). In that case we speak of a shared cache. The mechanism is the same, but sometimes we can control whether a specific resource is stored in the client (private) or the shared (public) store, for example with the Cache-Control header.

Etag header

The first header in our review is Etag (entity tag). It is essentially a short digest of the requested content or a version of the document. The exact algorithm for producing an Etag is not defined by the specification — it can be a hash of the content, a hash of the update date, or a version. An example header in Angie is shown below.

Etag "688a29f2-1597"

By default, Etag generation in Angie is enabled for static requests. The main inputs for Etag are the file's modification time and its size. The etag directive can be configured at the http, server and location levels. Generation can be switched off in any of these blocks with a single line:

etag off;

After receiving a response with an Etag header, the client stores it in the cache and validates on subsequent requests with the If-None-Match header.

If-None-Match: "688a29f2-1597"

If the document on the server has the same Etag (that is, it has not been changed), the client gets a response with no body (headers only) and status 304 (Not Modified).

Etag has a variant with a weak validator (weak validator). Here the prefix W/ is added in front of the value. With weak validation, responses carrying the same tag value may be semantically equivalent but differ in content. A web page, for example, may show different ad blocks or a different date in the text. Weak Etags allow caching a document as a whole, but they do not allow caching range requests. Angie applies weak etag during compression and decompression, image resizing in the image filter, the addition filter, XSLT transformation with the xslt_last_modified on option, ssi with the ssi_last_modified on directive, and the sub filter with sub_filter_last_modified on. An example of a weak Etag is shown below.

Etag: W/"68c2f468-30b6"

Using the Etag header is a good fit for dynamic requests, where constant validation and fresh data are required. In that case the header is produced by the backend from a hash of the content's modification date or of the content itself.

Last-Modified header

As we said earlier, the Etag header in Angie uses the document's modification time as one of its inputs. There is also a header with the plain change time of the document: Last-Modified. Its behavior is similar to Etag, but its generation and semantics are more transparent. For example, when serving static files the header carries the filesystem's mtime (in GMT format):

Last-Modified: Fri, 06 Sep 2024 16:10:23 GMT

On receiving a document with such a header, the browser can store it in the cache (as long as other headers do not forbid it). The next request then performs validation with the If-Modified-Since header.

If-Modified-Since: Fri, 06 Sep 2024 16:10:23 GMT

As with Etag, the server has to check the response and, if there is no fresher version, return only headers with status 304 (Not Modified). If the resource has been updated, you get a normal response with headers and a body, status 200.

In Angie the Last-Modified header is not configured separately; it is set automatically for static requests (serving files from disk). If a request is proxied, the header is passed to the client by default. When the same files are served from several servers, take care to synchronize the mtime attribute for those files, so the caching headers do not depend on which server happens to produce the response.

Like Etag, the Last-Modified header suits dynamic requests and can be set by the backend from the document's edit date. It is also useful for search crawlers, since it lets them avoid unnecessary re-indexing of a site's pages.

Expires header

The previous headers assume a validation mechanism when cached resources are reused. That means issuing requests and receiving 304 responses, which can be excessive when we are talking about fully static documents. This is where the Expires header comes into play (and Cache-Control, but more on that in a moment). An example of such a header is shown below.

Expires: Tue, 25 Aug 2026 14:34:46 GMT

With the Expires header we set a TTL (Time To Live) for cache entries. Until that period expires, the client must not check the document for updates. This removes extra conditional requests, but we lose server-side control over document updates. The Expires header (and Cache-Control at the same time) can be set with the expires directive.

expires 1y;

Static documents are usually given a long cache lifetime. In our example it is one year from the current date and time (1y), but there is also the special value max (the year 2037). The directive also supports the modified parameter, which changes the date calculation algorithm to the file's modification date plus the time parameter. Negative values indicate that the resource is already stale (caching is disabled).

So this header is a good fit for a "forever" cache of static resources. Cache invalidation, however, now has to happen at the web application level. What could be better for static assets? Only the Cache-Control header.

Cache-Control header

We have arrived at the mightiest of the client caching headers. Cache-Control works along the same lines as Expires (it does not require conditional requests) but has more flexible settings. When the Cache-Control header is set, the Expires value is ignored by the client. Let's look at an example. With this header you can implement a "forever" cache for static assets, with many parameters that refine its behavior.

Cache-Control: max-age=31536000, public, no-transform, immutable

The cache lifetime here is given relative to the current time, in seconds (max-age). This is far more convenient in configuration, since no time arithmetic is needed. The max-age parameter can be zero, which means caching is forbidden.

Besides the caching period, the header allows a number of fine-grained caching settings. In the example above we allow the element to be cached in a public store (public), and forbid intermediate services from modifying or optimizing it (no-transform) — for example, traffic-saving services or image optimizers. Finally, we say that the resource does not need validation (immutable) while it is within its cache lifetime.

There is also a way to allow a stale cache entry to be used while validation is in progress, via stale-while-revalidate. An example is shown below.

Cache-Control: max-age=604800, stale-while-revalidate=86400

Here we set the cache lifetime to 7 days, but for a day after that a stale entry may be used while validation runs.

Caching is forbidden with the no-store and no-cache parameters. The first one blocks caching in any form, while the second allows the cache but requires the response to be validated on every use. So a full cache disable looks like this.

Cache-Control: no-store

Many parameters of the header can be used in requests. On a hard page refresh, for instance, a request Cache-Control header may be sent.

Cache-Control: no-cache

Configuring the Cache-Control header in Angie is quite simple and is done with the universal add_header directive. It makes sense to enable it selectively in locations that serve static assets:

location /static/ {
  add_header Cache-Control "max-age=31536000, public, no-transform, immutable";
}

You can check for caching headers in the browser's developer tools (DevTools) or with curl:

curl --head http://localhost/images/bg.jpg

HTTP/1.1 200 OK
Server: Angie/1.10.1
Date: Tue, 26 Aug 2025 09:29:45 GMT
Content-Type: image/jpeg
Content-Length: 37864
Last-Modified: Fri, 06 Sep 2024 15:38:16 GMT
Connection: keep-alive
Vary: Accept-Encoding
ETag: "66db21e8-93e8"
Cache-Control: max-age=31536000, public, no-transform, immutable
Accept-Ranges: bytes

Note the Vary response header. It also helps with the problem of caching resources correctly. Its value shows which response headers the response may depend on. The response, for example, may be compressed with different codecs, which the server detects from the Accept-Encoding header. This matters if the response is going to be cached in a shared store.

Flushing the "forever" cache

Suppose we have set the most permissive caching header for the resources in a location with static files. But what if a resource is updated? It is already cached at the client, and we allowed it to be used without validation. That means there will be no requests to the server. We cannot ask every visitor to force-refresh the site with Ctrl+F5 or the developer tools.

The solution for updating elements in a forever cache is simple: the resource's address has to change. You can do that in part of the resource path (this requires redirects or renaming files) or with extra GET parameters (which requires no extra steps). What exactly to add to the path or the query string is up to the app developers' imagination: a content hash, a version, a modification date, and so on. The main rule is that every time the file on disk changes, the resource's address must change. Meanwhile, the document that references these resources (CSS, JS, images, and so on) must not be cached for a long time without validation. Examples of cache-flushing schemes are shown below.

/static/oaf320e34d/1.css
/static/1.oaf320e34d.css
/static/1.css?ver=2
/static/1.css?20250820
/static/1.css?ts=1756197781

Inside the document, resources are referenced with the version taken into account:

<link href="/static/1.oaf320e34d.css" rel=stylesheet>

As soon as the resource is updated, the link to it changes, and it gets downloaded and cached again. This cache-flushing scheme also works for the other caching headers.

Summary

We have reviewed all the main client caching headers and learned how to use them to make sites load faster. And, of course, we set them up in Angie.

Left out of scope are the specifics of how caches behave in real browsers: the split between the memory cache and the disk cache, cache limits on mobile devices, and so on. Such details are worth knowing, but overall they do not change the approach to caching: we encourage caching of static resources as much as possible and assume the resource will be stored at the client. There are no guarantees, though, so in any case you need to optimize resource sizes, use compression, and apply other optimization techniques.