Caching is the highest-leverage performance win most teams ignore. Get your Cache-Control headers, validation, and asset fingerprinting right, and repeat visits become near instant. Here is a practical caching policy that works.
Web Performance
Most of the performance work I see people obsess over is expensive and slow to pay off: shaving kilobytes off a JavaScript bundle, hunting down a render-blocking script, rewriting a component so it hydrates a little faster. All of that matters, but it is downstream of a question most teams never bother to answer properly, which is what the browser and the CDN are allowed to keep. Caching is the single highest-leverage performance lever I know, and it is also the most ignored, because it lives in HTTP response headers that nobody looks at and that no dashboard flags as a problem. A site with mediocre code and excellent caching will beat a beautifully optimised site with careless caching every single time, for the simplest of reasons: the fastest request is the one that never leaves the device. This post is my working guide to how we think about caching at Identiti, from the browser to the edge, with the directives that actually matter, the asset policies we apply by default, the mistakes that quietly ruin everything, and how to verify that any of it is doing what you think.
The Three Layers, And Why Two Of Them Are Free
When a visitor requests a page, the response can be served from one of several places, and the further away the source, the slower and more expensive it is. The layer nearest the user is the browser cache, which lives on the visitor’s own device. If a file is there and still considered fresh, the browser serves it with zero network activity, which is as fast as the web gets. The next layer out is the CDN or edge cache, a network of servers positioned close to users around the world that hold copies of your responses so they can answer without troubling your origin. Beyond that sits your own infrastructure: a server or application cache (think Redis, a full-page cache, or memoised database queries) that saves your origin from recomputing the same expensive work over and over.
The reason I split them out is that they are governed by different mechanisms and reward different effort. Server and application caching is genuinely useful, but it is bespoke work that you build and maintain, and it only ever helps requests that reach your origin. Browser and CDN caching, by contrast, are largely free in the sense that they are already built and running. You do not write them, you instruct them, and the entire instruction set travels in a handful of HTTP response headers. Get those headers right and you push work outward, closer to the user, where it is faster and where it costs you nothing per request. That is the leverage. You are not building a cache, you are giving permission to caches that already exist and are waiting to help you.
For the rest of this post I am going to concentrate on the browser and CDN layers, because that is where the effort-to-payoff ratio is absurd. A single well-considered header policy applied across your asset types will do more for repeat-visit performance than a month of code optimisation, and it will keep paying out for as long as the site exists.
Cache-Control: The Header That Runs Everything
The Cache-Control response header is the instrument. Almost everything worth knowing about HTTP caching is a matter of understanding its directives and applying them deliberately rather than accepting whatever your server or framework happens to emit by default. The most important directive is max-age, which tells the cache how many seconds a response may be considered fresh. Cache-Control: max-age=3600 means “you may reuse this for one hour without asking me again”. During that hour the browser serves the file locally and makes no network request at all. This is the state you want your assets to be in as often as it is safe.
Then there is the pairing that trips people up constantly: no-cache and no-store. They sound like synonyms and they are nothing of the sort. no-store means do not keep a copy anywhere, ever; refetch the whole thing from the origin on every request. That is for genuinely sensitive or never-repeatable responses, like a page containing a one-time token or private financial data you do not want written to disk. no-cache, despite its name, does allow the response to be stored; it just requires the cache to revalidate with the origin before serving the stored copy. Revalidation, as we will see, is cheap when nothing has changed, because the server can reply “still good, use what you have” without resending the body. So no-cache is a “check first, then reuse” policy, while no-store is a “never keep it” policy. Reaching for no-store when you meant no-cache throws away a large, free optimisation.
The other directives fill in the details. public says any shared cache, including a CDN, may store the response, while private restricts storage to the end user’s browser and forbids shared caches from holding it, which matters for anything personalised. immutable is a promise that the file will never change for the life of its URL, which tells the browser not to bother revalidating even on a hard refresh. And stale-while-revalidate is the quietly brilliant one: it lets a cache serve a slightly stale response instantly while it fetches a fresh copy in the background, so the user never waits and the next request gets the update. A directive like max-age=60, stale-while-revalidate=86400 gives you a fresh window of a minute followed by a full day during which stale-but-instant is served while the refresh happens invisibly. For content that changes but where a few seconds of staleness is harmless, it is close to a free lunch.
Validation: ETag And Last-Modified For When Freshness Runs Out
Freshness eventually expires. When max-age runs out, or when you have used no-cache, the browser does not necessarily have to redownload the whole file. This is where validators come in, and they are how caching stays efficient for content that changes occasionally rather than never. There are two of them. Last-Modified is a timestamp the server sends with a response saying when the resource last changed. ETag is an opaque identifier, usually a hash or version string, that uniquely fingerprints a particular version of the response body.
The mechanism is a conditional request. When the browser has a stored-but-stale copy and needs to check it, it does not send a blank request; it sends the validator back to the server. With Last-Modified it sends an If-Modified-Since header carrying the timestamp; with ETag it sends If-None-Match carrying the identifier. The server compares what the browser has against the current version. If nothing has changed, it responds with a 304 Not Modified status and an empty body, and the browser reuses its stored copy. The saving here is real: you pay the round-trip latency of asking, but you skip redownloading the payload entirely, which for anything larger than a few kilobytes is the overwhelming majority of the cost.
I generally prefer ETag where I have the choice, because it is content-based rather than time-based and does not get fooled by a file that was rewritten with identical bytes or by clock skew between servers. That said, most well-configured servers emit both and the browser sorts it out. The thing to internalise is that validators and freshness are not competing strategies, they are two halves of one lifecycle. Freshness (max-age) lets you skip the network entirely for a while. Validation (ETag and Last-Modified) lets you skip the download when freshness lapses. A good policy uses both, tuned to how often the content actually changes and how tolerant you are of serving a slightly old version.
The Fingerprinting Pattern: Cache Forever, Change Anytime
Here is the technique that resolves the central tension of caching, and once it clicks, everything else falls into place. The tension is this: you want to cache assets for as long as possible so repeat visitors get them instantly, but you also need to be able to change them and have users pick up the new version promptly. Long caching and easy updates seem to pull in opposite directions. Fingerprinted filenames make the conflict disappear.
The idea is to include a hash of the file’s contents in its filename. Instead of shipping app.js, your build tool produces app.9f3c2a1b.js, where that string is derived from the bytes of the file. Change one character of the source and the hash changes, so the filename changes: app.7d1e88f0.js. Because the URL is now different, it is by definition a different resource as far as any cache is concerned. This means you can cache the fingerprinted file with the most aggressive policy available, Cache-Control: public, max-age=31536000, immutable, which is one year and a promise never to revalidate. You are telling every cache in the world to keep this file effectively forever and never check back.
When you deploy a change, your HTML references the new filename, the browser sees a URL it has never fetched, and it downloads the new file while continuing to serve the old fingerprinted files from cache for any user who has not updated yet. There is no cache invalidation to orchestrate, no purge to trigger, no stale-asset window to worry about. The old version and the new version coexist under different names. This is why modern build tools do it automatically, and it is the backbone of every serious caching strategy: hashed assets cached for a year, referenced from an HTML document that is itself cached lightly or not at all. The HTML is the only thing that needs to change to point at the new hashes, which is exactly why the HTML must never be cached the way the assets are.
A Policy Per Asset Type: The Cheat Sheet In Prose
Different files deserve different treatment, and the whole game is matching the policy to how the file behaves. Here is the set of defaults we reach for, and the reasoning behind each, so you can adapt rather than copy blindly.
Start with HTML, which is the document that ties everything together and points at all your fingerprinted assets. HTML must be cached short or not at all, because it is the mechanism by which users discover new asset versions. If you cache HTML aggressively, a visitor holds on to an old document that references old asset hashes, and your deploy never reaches them. So for HTML we use either no-cache (store it but always revalidate, which is my usual default because a 304 is cheap) or a very short max-age measured in seconds to a few minutes, often paired with stale-while-revalidate so the revalidation never blocks rendering. The principle is that HTML should be revalidated frequently so that new asset references propagate quickly.
Hashed JavaScript, CSS, and images are the opposite case, and they get the maximum. Because the filename changes whenever the content changes, there is zero risk in caching them forever, so public, max-age=31536000, immutable is the right answer. One year, cacheable by CDNs and browsers alike, no revalidation. This is where the vast majority of your bytes live and where long caching pays out hardest, because a returning visitor downloads none of it. If your images are not part of the build pipeline and lack fingerprints, either bring them into the pipeline or cache them more conservatively with a validator, but the goal is always to get them fingerprinted so they can join the immutable tier. Getting your images into a modern format is part of the same housekeeping; we cover the practical side of that in converting images to WebP, and once they are fingerprinted they cache forever like any other hashed asset.
Fonts are a special and pleasing case, because a given font file genuinely never changes. A woff2 file is inert; version 1 of your typeface is byte-for-byte identical for its entire life. So fonts get a long max-age, a year is standard, ideally with immutable, and if you are serving them from your own origin you should fingerprint them too so you never have to think about it again. The one wrinkle worth flagging is that cross-origin font requests need correct CORS headers to be used at all, and getting fonts to load early enough to avoid a flash of unstyled text is a separate discipline that leans on resource hints like preload and preconnect rather than on caching. Cache the font hard, and hint it early; the two work together.
For anything dynamic or personalised, such as an API response tailored to a logged-in user, or a page fragment that differs per visitor, mark it private so shared caches never store it, and choose between a short max-age and no-cache depending on how fresh it must be. And for the genuinely sensitive, the tokens and the private data, that is the narrow, correct home of no-store. Everything else deserves better than being locked out of caching by an over-cautious default.
The Edge: CDN Caching And The Cache Key
The CDN is the second free cache, and it works on the same headers with one extra concept you have to understand or it will bite you: the cache key. A CDN stores responses and serves them to users near each edge location, which means the first visitor in a region pays the cost of fetching from your origin and everyone after them in that region is served instantly from the edge. For public, cacheable assets this turns your origin from something every user hits into something the CDN hits occasionally on everyone’s behalf. The public directive is what permits this, which is why personalised responses must be private, so they are not accidentally shared between users at the edge.
The cache key is how the CDN decides whether two requests are asking for the same thing. By default it is usually the URL, which is exactly what you want for fingerprinted assets: same URL, same content, one cached copy served to everyone. The trouble starts when the response actually varies by something other than the URL and the CDN does not know it. The classic trap is serving different content based on a request header, a cookie, or an Accept header, without telling the cache. If you serve WebP to browsers that accept it and JPEG to those that do not, both under the same URL, and you do not vary the cache key accordingly, the CDN will happily serve one visitor’s WebP to a browser that cannot render it. The Vary response header is the honest fix: Vary: Accept tells the cache to key on that header so each variant is stored separately.
The practical guidance is to keep cache keys as simple as the content honestly allows. Every dimension you add to the key (a cookie, a header, a query parameter) multiplies the number of stored variants and lowers your hit rate, because each variant is cached independently and each new combination is a fresh miss. Strip marketing query parameters from the key if they do not change the response, because otherwise the same asset arriving with a dozen different tracking tags becomes a dozen separate cache entries. Vary only on what genuinely changes the bytes. A clean cache key with a high hit rate is worth far more than an elaborate one that is technically correct but rarely reused. When you set an edge caching policy, you are really deciding two things at once: how long the edge holds a response, and what makes two requests count as the same. Both matter, and the second is the one people forget.
The Mistakes That Quietly Undo Everything
Most caching failures are not exotic. They are a handful of the same errors, and I have watched every one of them cost a client real speed. The first and most common is caching HTML too aggressively. Someone sets a blanket max-age across the whole site, or a CDN applies a default, and suddenly the HTML is cached for hours. Now a deploy goes out, the new fingerprinted assets are live, but returning visitors are still holding old HTML that points at old hashes, so they never see the change until their HTML expires. The site is not broken, which is what makes it insidious, it is just serving yesterday’s version to loyal users. HTML short, assets long: that ordering is the whole discipline, and inverting it is the most expensive mistake in caching.
The second is not versioning assets at all. If you cache app.js for a year without fingerprinting it, you have created a file you cannot update. Your only escape routes are renaming it by hand, appending a query string and hoping caches respect it (many CDNs ignore query strings in the cache key by default, so this often silently fails), or purging the CDN and praying every browser cooperates. None of these is reliable. The correct move is to never be in this position: fingerprint first, then cache forever. The long cache lifetime is only safe because the filename encodes the version. Take away the fingerprint and the long cache becomes a liability.
The third is the collision between caching and personalisation, and it is the scariest because it leaks data rather than merely serving stale content. If a page is personalised (it greets the user by name, shows their cart, reflects their permissions) and it is served with a public directive, a shared cache can store one person’s version and hand it to the next visitor. The fix is disciplined use of private for anything user-specific, no-store for anything sensitive, and never letting a personalised response fall through to a default public policy. The safest architecture keeps the personalised and the cacheable in separate responses: cache the shell hard and public, fetch the personalised parts separately and privately, and assemble them on the client. That way your cache hit rate stays high and no user ever sees another user’s data.
A quieter fourth mistake is treating caching as a set-and-forget task and never budgeting for it. Cache policy drifts: a new asset type ships without a rule, a framework upgrade changes the defaults, a CDN setting gets toggled during an unrelated fix. Caching belongs in your ongoing performance discipline alongside everything else, which is one reason we fold it into a performance budget that keeps sites fast rather than treating it as a one-time configuration. And a related trap worth naming: over-eager lazy loading and caching can interact badly if you defer assets that the browser could have served instantly from cache, so it pays to think about the two together, which is part of why lazy loading done right is about restraint as much as technique.
How To Actually Verify Your Caching
None of this is worth anything if you assume it works instead of checking, and caching is unusually easy to get wrong silently, because a misconfigured policy produces a site that looks completely normal while quietly refetching everything. So verification is not optional, and happily it is straightforward, because caching lives entirely in response headers you can read directly. Open your browser’s developer tools, go to the network panel, and load the page. For each request you can inspect the response headers and see exactly what Cache-Control, ETag, and Last-Modified were sent. That alone tells you whether your policy is being applied.
Then reload and watch the status column and the size column, because this is where the truth shows. A fingerprinted asset that is caching correctly will show as served from memory or disk cache on a repeat view, with no network transfer, which is the outcome you want. A request coming back as 200 with a full transfer size on every reload means it is not being cached at all and something is wrong with its headers. A request coming back as 304 Not Modified with a tiny transfer size means it is being revalidated: the browser asked, the server confirmed nothing changed, and only the headers moved, which is the correct and efficient behaviour for no-cache content but a sign of a too-short max-age if you see it on an asset that should be immutable. Learning to read those three states (served from cache, 304 revalidated, full 200 redownload) is most of the skill.
For the edge layer, look for the CDN’s own headers, because every major CDN reports whether a given response was a cache hit or miss, usually in a header carrying a HIT or MISS value. Request an asset twice from the same region and the second should report a hit; if it keeps reporting misses, your cache key is probably too specific or your directives are telling the edge not to store the response. Command-line tools are just as good for this as the browser: a request that prints the response headers lets you script checks across many URLs and catch a whole category of resource that has slipped out of policy. When we diagnose a slow site, caching headers are one of the first things we read, because a poor caching posture very often shows up as a sluggish largest-contentful-paint on repeat visits, and untangling that is a recurring theme in how to fix a failing LCP. Verify with headers, trust the status codes, and treat any full redownload of a should-be-cached asset as a bug to be fixed rather than noise to be ignored.
The Habit Worth Building
If you take one thing from all of this, let it be the ordering, because it is simple enough to hold in your head and it prevents the majority of mistakes. HTML is revalidated often so new versions propagate; fingerprinted assets are cached forever because their names guarantee it is safe; fonts are cached long because they never change; personalised responses are kept private; and truly sensitive responses are not stored at all. Everything else is tuning around that spine. The reason caching stays ignored is that it is invisible when it works and invisible when it fails, so nobody thinks to look until performance is bad for reasons a profiler cannot explain. Make reading response headers a habit, apply a deliberate policy per asset type instead of accepting defaults, and you will hand your repeat visitors a site that loads instantly, at a cost of almost nothing, using infrastructure that was already sitting there waiting for you to give it permission. That is the whole argument for caching being the highest-leverage performance work there is: the payoff is enormous, the effort is a set of headers, and the fastest request will always be the one your visitor never has to make.