Technology

Static Asset Caching: Cache-Control and Fingerprinting

Ever ship a tiny CSS tweak and watch it stubbornly refuse to show up for half your users? Caching is hilarious because when it’s right, nobody notices, and when it’s wrong, your support inbox catches fire. The good news: there’s a calm, reliable way to make static files behave. It boils down to three ingredients—strong Cache-Control with immutable, choosing between ETag and Last-Modified (or using them together wisely), and leaning hard into asset fingerprinting so cache invalidation becomes a non-event.

Let’s walk through a practical mental model: how browsers decide to reuse a file, when immutable avoids unnecessary revalidation, how conditional requests flow, and how a hash in your filename simplifies cache invalidation. We’ll cover header snippets and common pitfalls, with links to related guides on image optimization, HTTP/2+HTTP/3, and S3 offloading.

Why long caching needs versioned URLs

Suppose you ship a redesign with month-long max-age, then replace the logo SVG at the same URL a day later. Users with a fresh cached copy can keep seeing the old logo. The lesson: caching isn’t just about setting long expirations. It’s about versioning what you cache so updates don’t depend on recalling cached copies. For long-lived immutable assets, change the URL whenever the content changes. Once you internalize that, everything else—the headers, the CDNs, the browser gymnastics—becomes dramatically simpler.

Think of your static assets like jars of jam: a unique batch number keeps different batches from being confused. Asset fingerprinting is that batch number in the URL. Cache-Control: immutable tells caches they need not revalidate the stored response while it remains fresh; it does not set a freshness lifetime or guarantee that a cache will retain the response.

A friendly mental model of web caching

A useful mental model starts with two questions. First, “Can the browser reuse what it already has without asking the server?” That’s where Cache-Control and freshness come in. Second, “If it can’t reuse the response outright, can it check whether the server’s copy changed?” That’s where ETag and Last-Modified enter, with conditional requests that can save a body transfer but still require a network exchange.

So in your ideal flow, a versioned, fingerprinted filename lets you safely set cache headers that allow the browser to reuse the file for a very long time, without revalidating. When you ship a new version, the filename changes, and the browser fetches the new file once, then resumes hoarding it guilt-free. That’s the loop. That’s the serenity.

Think of HTML as fresh bread and public, fingerprinted JS, CSS, and images as canned beans. The bread gets served fresh—short caching or revalidation, no immutable, and private caching when personalized. The beans get a long shelf life—1 year max-age, immutable, public. Keep those roles straight and caching stops being mysterious.

Cache-Control and immutable: the quiet superpower

Let’s talk about the hero that rarely gets top billing: Cache-Control: immutable. This hint tells the browser, “This response will not change during its freshness lifetime, so routine revalidation is unnecessary while it is fresh.” It can avoid revalidation on ordinary reloads in supporting browsers, though a forced reload can still bypass it. Even if your max-age is long, lots of setups still trigger revalidation under some conditions. Immutable says, “Relax. Save the trip.”

Pair immutable with a generous max-age for public, fingerprinted assets whose content will not change at that URL:

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

Translated: allow storage in browsers and shared caches such as CDNs, treat the response as fresh for a year, and normally skip revalidation while it remains fresh. This setting is perfect for files whose URL carries a content hash: app.4f2c9f1.js, styles.7890ab.css, logo.12ac34.svg. If you don’t have a build pipeline adding hashes yet, we’ll get there—because it changes everything.

HTML is the odd one out. The document itself should usually not be immutable, and its max-age should be quite short or set for revalidation. You want to be able to ship new HTML quickly to reference your new asset URLs. In practice, that means your index page might carry headers like:

Cache-Control: no-cache

No-cache doesn’t mean “don’t store.” It requires successful validation before a stored response can be reused. This can save a body transfer when HTML is unchanged, but it still requires a network exchange. For personalized HTML, add private to prevent shared-cache storage; use no-store when storage itself must be prohibited.

If you like reading the official wording, MDN’s page on cache headers is solid and surprisingly readable. I’ll link it here because it’s handy for quick checks: MDN on Cache-Control directives (including immutable).

ETag vs Last-Modified: the check-in desk

If ETags differ across server nodes for equivalent responses, conditional requests can return unnecessary full responses instead of 304 Not Modified. Stable validators across nodes avoid that mismatch. Weak ETags can support 304 responses too: weakness alone is not the problem, and stronger validators do not change how often freshness rules require revalidation.

ETag is an opaque validator for a representation, often generated from a hash. A strong ETag distinguishes byte-level changes; a weak one can identify semantically equivalent representations. Last-Modified is the server’s timestamp for the representation’s last modification, often the file’s modification time for static assets, with one-second resolution. Deployments that change timestamps without changing content can cause unnecessary transfers, as can ETags that differ per node or include volatile metadata. Choose validators you can keep stable for unchanged representations and update when required by their semantics. Content-based ETags are one option; sending both headers is useful when modification timestamps are also reliable.

In practice, a public, fingerprinted asset might include both. When a request includes both If-None-Match and If-Modified-Since, If-None-Match takes precedence:

ETag: "4f2c9f1-1a2b3c4d"
Last-Modified: Tue, 22 Oct 2024 19:20:30 GMT
Cache-Control: public, max-age=31536000, immutable

With long freshness and immutable, a cached fingerprinted asset normally needs no conditional request until it goes stale. Validators can save a body transfer when revalidation is needed, but they do not make a fresh cached response check for updates. An unversioned URL with a long freshness lifetime can therefore keep serving old content after a change. For URLs that change in place, use short freshness or revalidation rather than relying on validators alone.

MDN also has straightforward references for these headers if you want exact semantics. For quick lookup: ETag header.

Asset fingerprinting: the stress antidote

Let’s talk about the piece that makes everything else boring—in a good way. Asset fingerprinting (or content hashing) means you bake a hash of the file’s contents into the filename or path. Your CSS might become styles.7890ab.css. If you change a single character, the hash changes, and so does the filename. With a new URL for changed content, cached old files do not need purging for routine asset updates. Users fetch the new asset once they receive HTML or other references pointing to it. Keep older assets available while cached pages and open sessions may still reference them.

Compared with timestamps that change on every build, content hashes let unchanged assets retain their URLs. Long-lived immutable caching for public, hashed JS/CSS/images can then preserve reuse across deployments, while short-lived or revalidated HTML delivers updated references.

Bundlers such as Webpack, Rollup, Vite, and Parcel offer content-hashed output, but configuration and defaults differ. Check the generated filenames and references rather than assuming a single flag covers every asset. For a custom build, scripts can calculate a content hash, include it in the filename, and rewrite HTML templates and dependent asset references. Keep that process deterministic so unchanged files retain their URLs.

For large media libraries, versioned object keys can support caching when storage and delivery are offloaded. This related guide covers signed URLs and invalidation: Offloading WordPress media to S3-compatible storage and doing cache invalidation the right way. The versioning principles also apply outside WordPress, but private media requires coordinated access controls and cache policies rather than the public-asset defaults above.

Header patterns to adapt to your stack

Alright, let’s put rubber on the road. Below are simple header patterns to adapt to your stack and freshness requirements.

For public fingerprinted static assets (JS, CSS, images, fonts), replace the placeholders below. The ETag must identify the served representation; strong ETags must differ between byte-distinct encodings. Use an HTTP-date for Last-Modified that reflects the representation’s last modification. The build-time placeholder is appropriate only when it records that modification, not a new timestamp for every deployment of unchanged bytes. Omit Last-Modified if no reliable modification time is available.

Cache-Control: public, max-age=31536000, immutable
ETag: "<content-hash>"
Last-Modified: <build-time>

For public HTML documents and API responses that must be revalidated before reuse. For personalized responses, add private to prevent shared-cache storage; use no-store when storage itself must be prohibited:

Cache-Control: no-cache
ETag: "<content-hash-or-version>"

For public, non-personalized static HTML that updates occasionally but can tolerate short freshness windows:

Cache-Control: public, max-age=60, must-revalidate

In Nginx, the example below assumes a static-site root containing only public files, with every matching asset fingerprinted. It matches extensions, not hashes; scope it to your versioned asset paths otherwise. Adapt these locations within the existing server configuration rather than replacing application proxy or FastCGI routing. Nginx uses the first matching regex location, while exact and ^~ locations can take precedence. The HTML rule covers .html URLs, not extensionless application routes; configure those responses in their own handlers. expires off disables inherited expiration generation. Adding add_header directives can change header inheritance, so preserve other required response headers and inspect the final response.

location ~* [.](?:js|css|png|jpg|jpeg|gif|svg|webp|avif|woff2?)$ {
    try_files $uri =404;
    expires off;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

location ~* [.](?:html)$ {
    try_files $uri =404;
    expires off;
    add_header Cache-Control "no-cache";
}

With a CDN in front, distinguish browser freshness from shared-cache freshness. Edge TTLs do not have to equal or exceed the browser’s one-year lifetime; configure each layer for its role and check the headers actually delivered to users. Shared-cache directives such as s-maxage or CDN-specific rules can set a separate edge policy. An edge purge cannot recall a still-fresh browser copy, so changed asset content still needs a new URL. For related transport optimizations, see Enabling HTTP/2 and HTTP/3 on Nginx + Cloudflare, end-to-end. Protocol improvements complement caching for requests that still need the network.

A word on images, compression, and why cache keys matter

Images can account for a large share of page weight. AVIF/WebP variants can use distinct URLs, query parameters, or Accept-based negotiation. If one URL serves different formats based on Accept, return the appropriate Vary header and ensure the CDN distinguishes those variants. Response-changing query parameters must also participate in the cache key. For related guidance on format negotiation, origin shielding, and cache keys, see Building an image optimization pipeline with AVIF/WebP and smarter cache keys.

Brotli can produce smaller JS and CSS transfers than gzip, depending on the content and compression settings. Static precompression moves that work to build time rather than repeating it at runtime, provided the server is configured to serve the precompressed files with correct encoding and variation headers. For related configuration guidance, see TLS 1.3 and Brotli on Nginx—the speed-and-security tune-up. Precompression and immutable caching address different costs: compression work and repeat network requests.

CDNs, invalidation, and avoiding the “stale but broken” release

For routine fingerprinted-asset updates, publish new URLs instead of purging old asset responses. HTML and other mutable references may still need revalidation or targeted edge invalidation. Release assets first, then the HTML that references them, and retain previously published assets while cached pages or open sessions may still use them. With those files available, a page loading during deployment can still use the previous HTML; how long it persists depends on its caching policy. Publishing HTML first risks asset 404s during the deployment window.

Immutable is a trust contract, so don’t cheat it. If you ever push new content to an old filename, you’re going to confuse caches across every layer and device. That’s when you start hunting down weird ghost responses and muttering about proton storms. Publish changed content at a new URL. Removing immutable or shortening max-age at the origin does not update copies already fresh in browser caches. For URLs that must change in place, choose short freshness or revalidation before distributing them.

For big media libraries, versioned object keys make cached content stable. Set Cache-Control when uploading an S3 object or updating its metadata. S3 Versioning alone does not change the URL referenced by a page: publish changed content under a new object key, or explicitly distinguish the version in both the request and cache key. Signed URLs control access, but cache keys, signature validation and cache lifetimes must be coordinated so cached responses do not bypass the intended access policy. Do not copy public-asset defaults onto private media. The S3-compatible storage guide linked earlier covers related implementation steps. If invalidation is needed, purge explicitly and narrowly rather than clearing unrelated cached content.

For CloudFront, check the cache behavior and the cache policy’s minimum, default and maximum TTLs. A minimum TTL greater than zero can cause CloudFront to cache responses even when the origin sends no-cache, no-store or private. Set the minimum TTL to zero where those directives must be respected; coordinate disabled caching and access controls for sensitive routes. Browser and edge lifetimes need not match. Check delivered headers and error/stale policies as well. On Cloudflare, immutable does not itself affect the shared edge cache, and Cache Rules can override the origin policy.

Implementation notes for Nginx, Apache, Node, and friends

Every stack handles headers differently, but the strategy stays the same: generate stable asset names and validator metadata during the build where practical, then configure response headers at the application, web server, or edge. Check the final response so one layer does not unintentionally override another.

In Nginx, location-based rules can scope cache policies; precompressed .br/.gz files also need correct Vary and Content-Encoding headers. In Apache, mod_headers can set cache headers; check that file-based ETags remain consistent across nodes, and configure or disable them if they produce unnecessary mismatches. In Node, Express or Fastify can set headers per route or through static-serving middleware. Restrict a fixed long-lived public policy to public, fingerprinted assets rather than applying it to every static route.

Generating content-based ETags during the build can give nodes serving identical representations consistent validators. Strong ETags must differ for byte-distinct representations, including different content encodings; appropriately weak validators can compare semantic equivalence. A CDN may answer conditional requests itself rather than pass through an origin 304. If build-generated validators are impractical, use reliable server-generated validators where available. For public fingerprinted assets, long freshness with immutable still minimizes routine revalidation; the filename hash alone is not an HTTP validator.

If you’re containerized or orchestrating multiple services, release flow matters as much as headers. For related deployment guidance using release directories and symlink swaps, see Zero-downtime CI/CD to a VPS using rsync and symlinked releases. Keep asset availability and retention part of that release plan.

Testing, verifying, and fixing the sneaky stuff

Check caching from three perspectives. In the browser, use the DevTools Network panel to inspect headers and memory/disk-cache reuse on first and repeat views; leave “Disable cache” unchecked when testing reuse, and distinguish ordinary navigation from reload behavior. At the edge, inspect headers with curl -I and compare them with a direct origin request where available. To test revalidation, send If-None-Match with a previously returned ETag; an unconditional HEAD request alone does not test 304 behavior. Finally, throttle the network and compare an empty cache with a populated one. These are checks to perform on your deployment, not reported test results.

Lighthouse and WebPageTest can help identify caching opportunities. Use their findings alongside actual response headers rather than treating an audit score as proof of correctness. For background, see why long cache TTLs improve performance. Optimize for the flow: HTML that can deliver updated references promptly and stable asset URLs that can be reused safely.

Keep an eye on Vary headers. Varying static assets on high-cardinality headers such as Cookie can fragment a cache and reduce reuse. Avoid unnecessary cookies and Vary fields, but retain those required for negotiation: Accept for header-negotiated image formats and Accept-Encoding for compressed variants. Ensure the CDN cache key distinguishes those variants and any query parameters that change the response. Check whether your framework adds cookies or variation to asset routes unintentionally.

Common pitfalls to avoid

Three pitfalls deserve attention. First, unversioned CSS with a 1-year immutable policy can remain stale after updates; reserve that policy for URLs whose content will not change. Second, per-node timestamp footers produce different file bytes and therefore different content hashes. Build identical artifacts across nodes; do not strip bytes only from a strong ETag calculation while still serving those bytes. Third, incorrect compression handling can leave Content-Length describing the wrong representation. If sent, Content-Length must match the encoded body, and Content-Encoding must describe the encoding actually applied.

A CDN cache hit does not necessarily eliminate a browser network request. The edge may serve a cached 200 response while the browser still revalidates because its policy has short freshness or requires validation. Missing immutable may permit reload-time revalidation while fresh, but does not by itself require validation on ordinary reuse. For public, fingerprinted assets, long freshness with immutable lets the browser avoid routine requests. Keep mutable and personalized responses on policies appropriate to their update and privacy requirements.

Putting it all together: a simple checklist

Use this checklist during deployment. First, are CSS/JS/images/fonts fingerprinted before receiving long-lived cache headers? Second, do publicly shareable fingerprinted assets ship with Cache-Control: public, max-age=31536000, immutable? Third, does HTML use revalidation or short freshness, with private for personalized responses and no-store where storage must be prohibited? Fourth, are validators consistent across nodes, with strong ETags distinguishing byte changes and weak ETags tracking semantic equivalence? Fifth, are unnecessary cookies and Vary fields removed while required negotiated variants remain distinct in cache keys? Sixth, can you release assets before HTML and retain older assets while cached pages or active clients may still reference them?

Run through that list to catch common cache problems. For issues that remain, use response headers and logs to narrow down the cause.

Wrap-up: make caching boring (and fast)

For a logo that won’t update, consider how its URL and cache policy interact. Asset fingerprinting gives changed content a new URL; a long freshness lifetime lets browsers reuse the old URL’s response, while immutable discourages unnecessary revalidation while it remains fresh. ETag and Last-Modified remain useful validators when a stored response needs revalidation.

Begin with the build: add hashes to filenames and update HTML and dependent asset references. Then set long-lived immutable headers for public fingerprinted assets and conservative caching for HTML, with privacy controls where needed. Verify browser reuse, edge and origin headers, conditional responses, and behavior on a throttled connection. For complementary performance work, see the guides on HTTP/2 and HTTP/3, modern image pipelines with smart cache keys, and offloading media to S3-compatible storage with clean invalidation.

Make caching boring and predictable to reduce repeat downloads and cache-related deployment problems. Hope this was helpful! If you’ve got a caching war story, I’d love to hear it next time. Until then, ship calmly.

Frequently Asked Questions

Choose reliable validators: content-based ETags can stay consistent across nodes, while Last-Modified depends on reliable modification timestamps. Strong ETags must distinguish byte-distinct representations, including compressed variants. You can send both headers; If-None-Match takes precedence over If-Modified-Since. Fresh fingerprinted assets with immutable normally need no revalidation.

Pair immutable with a long max-age for public, fingerprinted assets whose content will not change at that URL. Publish changed content at a new URL. Immutable does not set freshness by itself; keep HTML on short freshness or revalidation so it can deliver updated asset references.

Long browser cache lifetimes do not require a CDN. A CDN adds a shared cache layer, while browser caching lets users reuse locally stored responses. If you use both, coordinate cache policies and ensure the CDN cache key distinguishes negotiated variants and response-changing query parameters.
↑