If a returning visitor waits just as long as a first time visitor, your caching is broken. A browser that has already downloaded your logo, your fonts, and your stylesheet should not download them again. When it does, you are paying for the same bytes twice and the user is paying with their time.
This is one of the first things we check on a rescue project. A site can have perfect images, clean code, and a fast server, and still feel sluggish because every single request goes back to the origin. Caching is the cheapest performance win on the web, and it is the one most often left at whatever the host set by default.
The good news is that fixing it is mostly a matter of sending the right headers. No rebuild required.
HTTP caching is the set of rules that decide whether a browser or a server in between can store a copy of a file and reuse it later instead of asking your server for it again. The rules live in response headers. Your server sends them, and every cache along the path obeys them.
The current specification is RFC 9111, published by the IETF in June 2022 as a Standards Track document that obsoletes the older RFC 7234. It defines what a cache is allowed to store, how long a stored copy stays fresh, and what has to happen when it goes stale.
There are two kinds of cache to keep straight. A private cache belongs to one person, which is the browser cache on their laptop. A shared cache sits in front of many people, which is your CDN. The same header can mean very different things depending on which one is reading it.
Cache-Control is the header that tells caches what they may store and for how long. The most common directive is max-age, which sets the freshness lifetime in seconds. As MDN's HTTP caching guide puts it, if the age of a response is less than that value the response is fresh, and if it is more the response is stale.
So Cache-Control with max-age=604800 means a browser can reuse that file for one week without asking again. Setting max-age=31536000 gives it a full year, which is the standard value for files that never change.
You can also mark a response as public or private. Private restricts storage to the browser cache, which matters for anything personalised. MDN describes a private cache as one tied to a specific client, so it can safely hold a personalised response that a shared cache never should.
If you send no Cache-Control header at all, caches guess. MDN calls this heuristic caching and notes that the specification suggests reusing a response for roughly ten percent of the time since it was last modified. It also says plainly that heuristic caching is a workaround and that basically all responses should specify a Cache-Control header explicitly. We agree. Guessing is not a strategy.
They sound identical and do completely different jobs. The directive no-store means do not keep a copy anywhere. The directive no-cache means keep a copy, but check with the server before using it. Most people who reach for no-store actually want no-cache, and they end up throwing away a free saving.
MDN is precise about this. It states that no-cache "does not prevent the storing of responses but instead prevents the reuse of responses without revalidation". The copy stays on disk. The browser just asks first.
That distinction matters for money as well as speed. A no-cache response usually comes back as a small 304 Not Modified with no body, so you skip the download entirely. A no-store response downloads the whole thing every time. Use no-store for genuinely sensitive data like a logged in account page, and no-cache for HTML you want fresh but cheap.
Split your files into two buckets. Anything with a content hash in its filename gets a year. Anything without one gets a short lifetime or revalidation on every request. Your CSS bundle named app.a1b2c3.css can be cached forever. Your homepage HTML cannot.
This is the cache busting pattern, and it works because the filename changes whenever the content changes. A new deploy produces app.d4e5f6.css, which is a different URL, so the old cached copy is simply never requested again. You never have to invalidate anything.
For those hashed assets, add the immutable directive alongside a long max-age. Immutable is defined in RFC 8246, published in September 2017, and its abstract explains that it identifies resources that will not be updated during their freshness lifetime, so a client never needs to revalidate to be certain the file has not been modified. That saves a network round trip on every reload.
Fonts, images, and JavaScript bundles all belong in the long lived bucket once they are hashed. Getting this right removes a surprising amount of repeat download work, which is closely tied to how quickly your main content appears. We covered that relationship in our guide to Largest Contentful Paint.
An ETag is a fingerprint your server attaches to a response so it can later tell whether the copy held by the browser is still current. The browser sends the fingerprint back in an If-None-Match header. If it matches, the server replies 304 Not Modified and sends no body at all.
MDN notes that the ETag value is arbitrary and the server can generate it however it likes, commonly as a hash of the response body or a version number. There is no required format, which means you control the cost of computing it.
The older approach is Last-Modified paired with If-Modified-Since, which compares timestamps instead of fingerprints. Both work, and you can send both. MDN points out that when both are present, If-None-Match takes precedence as the validator.
In practice we use ETags for HTML and API responses that change unpredictably, and skip them entirely for hashed static assets. If a file is immutable for a year, there is nothing to revalidate.
It is a directive that lets a cache serve a slightly out of date copy immediately while it fetches a fresh one in the background. The visitor gets an instant response. The next visitor gets the updated version. For most marketing sites this is close to free performance.
The extension comes from RFC 5861, published in May 2010 under the title "HTTP Cache-Control Extensions for Stale Content". The same document defines stale-if-error, which lets a cache serve a stale copy when the origin returns a 5xx error or cannot be reached at all.
We think stale-if-error is underused. It turns a short origin outage into a slightly old page instead of an error screen. For a site where the content changes a few times a week, serving yesterday's copy during a blip is obviously better than serving nothing.
The trade off is honest and worth stating. Somebody will occasionally see content that is a few seconds or minutes old. If that is unacceptable for your use case, such as live pricing or stock levels, do not use it there.
A CDN adds a shared cache between your visitors and your origin, so your headers now control two layers instead of one. Get it right and most requests never touch your server. Get it wrong and you either serve stale pages to everyone or cache nothing at all.
Defaults vary more than people expect. Cloudflare's own documentation states that its CDN does not cache HTML or JSON by default, and that it matches on file extension rather than content type. So your assets are cached and your pages are not, unless you write a rule that says otherwise.
Cloudflare also documents that it will not cache a response whose headers say private, no-store, no-cache, or max-age=0, and that when a response arrives with no cache headers at all it applies its own defaults based on status code, including 120 minutes for a 200 response. That last behaviour catches teams out. Send nothing and you still get caching, just not the caching you chose.
If you are still deciding whether you need a CDN in the first place, our explainer on what a CDN actually does covers the trade offs before you start tuning headers.
Open Chrome DevTools, go to the Network tab, and reload the page twice. On the second load, look at the Size column. Files served from cache say "memory cache" or "disk cache" instead of a byte count. Anything still showing a real transfer size is not being cached.
Then check the headers themselves. Click any request and read the Response Headers panel for Cache-Control, ETag, and Age. The Age header tells you how many seconds a shared cache has been holding that copy, which is the fastest way to confirm your CDN is doing its job.
For a broader view, run the page through Lighthouse or WebPageTest. Lighthouse flags a specific audit for static assets served with an inefficient cache policy, and it lists every offending file with its current lifetime. That list is usually your entire task list.
One last check that people skip. Caching improves repeat visits, but it also cuts server work, which shows up in your server response time. If your numbers there look poor, our piece on Time to First Byte explains what else to look at.
Start with one change. Give every hashed static asset a Cache-Control header of max-age=31536000 with immutable, and give your HTML no-cache so it always revalidates. Those two rules cover most of the benefit and carry almost no risk of serving stale content.
After that, look at your CDN configuration and confirm it is doing what you think. Most teams we work with find at least one file type falling through with no policy at all. Fixing it takes minutes and the effect lasts as long as the site does.
If you would rather have someone go through your headers with you and tell you exactly what to change, we are glad to do that. Reach out at phoenix.studio and we can take a look together.
Tell us where you want to go. We'll tell you how we'd get you there.