Cache-Control looks simple until you actually have to write one for a real endpoint and realize there are six directives that all interact with each other. Here's a step-by-step way to arrive at a header that does what you actually want instead of guessing at a value and hoping.
Step 1: Classify the Resource First
Before writing a single directive, decide which bucket the resource falls into: static and immutable (a fingerprinted JS bundle), semi-static (a product listing that syncs on a schedule), or dynamic and user-specific (an account page). The header you need is completely different for each, so skipping this step is how you end up with one Cache-Control value copy-pasted across endpoints that have nothing in common.
Getting this classification wrong in either direction causes real problems: caching dynamic data too long serves stale or wrong content, while not caching static assets at all wastes bandwidth and slows every repeat visit for no reason.
Step 2: Pick max-age Based on How Wrong You Can Tolerate Being
For anything cacheable, max-age sets how many seconds a response can be reused before it's considered stale. The right value isn't about performance, it's about how wrong you're willing to let a user be. A fingerprinted asset can have a max-age of a year, because the filename changes on every deploy. A product price that updates hourly probably shouldn't have a max-age past a few minutes.
Resist the urge to set a long max-age just because it improves a performance score. web.dev has good guidance on balancing cache duration against freshness requirements for different resource types.
It helps to write the tolerance down explicitly rather than picking a round number out of habit. "This can be five minutes stale because the source syncs every ten" is a decision you can defend later. "I set it to 300 because that felt reasonable" is not, and it's the kind of value that gets copy-pasted to the next endpoint without anyone re-deriving whether it still makes sense.
Step 3: Add must-revalidate or stale-while-revalidate Deliberately
Once max-age expires, what should happen next matters as much as the expiry itself. must-revalidate forces a fresh check with the origin server before reusing anything past expiry, which is the safer default for data where staleness is costly. stale-while-revalidate lets the cache serve the expired value immediately while it fetches a fresh one in the background, trading a small amount of staleness for a faster perceived response.
Neither is universally correct. Pick must-revalidate for anything where a wrong value has real consequences, and stale-while-revalidate for anything where speed matters more than being perfectly current for a few extra seconds.
Step 4: Set private or public Based on Who Can See It
This is the step that causes the most damaging bugs when skipped. private tells shared caches, a CDN, a corporate proxy, that only the end user's own browser may cache the response. public allows any cache along the way to store and reuse it for other users. Forgetting private on a response containing user-specific data is how one customer's cached page ends up served to another.
If a response varies based on the authenticated user, always default to private unless you have a specific reason and a Vary header to back up doing otherwise.
A useful habit here is treating public as something you opt into deliberately, with a specific reason written down, rather than something that happens by omission. Defaulting to private and loosening it only when you've confirmed a response is genuinely safe to share across users catches far more of these bugs than the reverse default ever will.
Step 5: Verify With the Actual Response, Not Just the Code
Once the header is written, check what's actually being sent. A curl -I against the live endpoint, or the Network tab in your browser's DevTools, shows the real header value, catching cases where a proxy, load balancer, or CDN configuration silently overrides what your application code set. MDN's Cache-Control reference is the most reliable place to double-check directive syntax and interactions while you're verifying.
"Most Cache-Control bugs aren't the header being wrong in the code. They're a CDN or proxy layer quietly rewriting it before it reaches the browser." - Dennis Traina, founder of 137Foundry
Step 6: Confirm Behavior Under a Real CDN
If a CDN sits in front of your app, its own cache configuration can override or ignore your origin's Cache-Control header depending on how it's set up. Cloudflare and similar providers typically let you inspect cache status through a response header of their own, which is worth checking alongside your origin headers rather than assuming the origin's value is the final word.
This step catches a specific and common failure mode: an engineer fixes the header at the origin, tests it locally, ships it, and the CDN's own cache rules mean nothing actually changed in production.
Step 7: Document the Decision, Not Just the Value
A Cache-Control header without a comment explaining why that specific value was chosen is a trap for the next person who touches the endpoint. Note the resource classification from Step 1 and the reasoning behind the max-age choice directly next to the header, so a future change doesn't accidentally undo a deliberate decision.
Step 8: Revisit It When the Resource's Behavior Changes
A header that was correct when it was written can become wrong silently when the underlying data's update frequency changes. An endpoint that used to sync hourly and now syncs every minute needs its max-age revisited, but nothing forces that conversation to happen unless the header's reasoning is documented somewhere a future change would surface it. Treat a change to a resource's update cadence as a trigger to re-check its caching header, not just its business logic.
A Worked Example
Say you're setting the header for a public blog post's API response. It's not user-specific, so private is out and public is correct. It changes rarely after publish, so a max-age of an hour with stale-while-revalidate set to a similar window is reasonable, giving readers a fast response while a background refresh catches the rare post-publish edit. Compare that to an authenticated user's notification count: user-specific, so private is mandatory, and volatile enough that a short max-age of a few seconds paired with must-revalidate makes more sense than trusting a longer cache window.
Walking through a couple of real endpoints like this, out loud, with the classification from Step 1 explicit, is a good exercise to run with a team the first time you're standardizing caching headers across a codebase that's grown organically.
Putting It All Together
None of these steps are complicated individually. The value comes from doing them in order instead of skipping straight to copying a header value from another endpoint that solved a different problem. A longer guide covering the full client-side caching strategy, including how this fits with application-level and service worker caching, walks through the bigger picture this header decision sits inside.
For teams building this out for the first time, 137Foundry's homepage has more on how we work through performance and caching audits with client engineering teams.
Top comments (0)