Skip to content

필사 모드: Using HTTP Caching Properly — What Cache-Control, ETag, and stale-while-revalidate Actually Mean

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

Introduction — you deployed, and users still see the old bundle

The deploy is finished, the new files are on the server, and an incognito window shows the new screen. Yet some users still see the old one. Telling them to force-refresh fixes it, but the moment that instruction is needed, the caching design is already wrong.

HTTP caching is not a matter of memorizing a handful of directives. It is a matter of knowing which layer holds what, for how long, and how you can take it back. And there is one layer you cannot take anything back from: the user's browser.

The exact meaning of each Cache-Control directive

Start with the directives that get misread most.

DirectiveExact meaningCommon misreading
max-age=NFresh for N seconds from the time the response was generated. While fresh, the origin is not consultedThat the browser checks every N seconds
s-maxage=NApplies only to shared caches, and overrides max-age thereThat it applies to the browser too
no-cacheStore it, but always validate with the origin before reuseRead as do not cache
no-storeDo not store in any cache. Do not leave it on disk eitherRead as the same thing as no-cache
must-revalidateOnce freshness ends, do not serve without validating. Do not serve stale on error eitherRead as always validate
privateStorable only in a single-user cache such as a browserRead as encryption or access control
publicMarks a response that would not normally be stored as storable in a shared cacheRead as the switch that turns caching on
immutableWhile fresh, do not revalidate even on a refreshRead as cached forever
stale-while-revalidate=NFor N seconds after expiry, serve the stale response immediately and refresh in the backgroundRead as the same as raising max-age

The difference between no-cache and no-store is decisive. Put no-cache on the account page of a logged-in user and the response is written to disk. Press back on a shared PC, or go digging through the cache directory, and the content is still there. To block storage itself, it has to be no-store.

Conversely, here is a combination you see all the time.

Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: 0

With no-store present nothing is stored, so the rest is meaningless. must-revalidate is a rule about stored responses, and Pragma is a request header from the HTTP/1.0 era, so putting it on a response does nothing that a modern cache will read. It gets copied around as a convention, but the single line Cache-Control: no-store is enough.

One more thing to know is that sending no cache headers at all does not mean caching is forbidden. Without explicit freshness information, a cache may estimate a lifetime heuristically, and conventionally uses about 10 percent of the time elapsed since Last-Modified. For a file modified a month ago, that means it can be cached for roughly three days. The assumption "we did not configure caching, so it will not be cached" does not hold.

Conditional requests and 304 — what they save and what they do not

A cache does not throw away a response whose freshness has ended. It takes a validator and asks the origin.

GET /assets/logo.svg HTTP/1.1
Host: cdn.example.com
If-None-Match: "8f3c1a20-4e2"
If-Modified-Since: Wed, 09 Jul 2026 11:20:14 GMT
HTTP/1.1 304 Not Modified
ETag: "8f3c1a20-4e2"
Cache-Control: max-age=600
Date: Sun, 26 Jul 2026 04:20:11 GMT

There is no body. The 12KB SVG was not downloaded again, so bandwidth was saved.

Here is the important point. A 304 saves bandwidth but still costs a full round trip. On a mobile connection with 180 milliseconds of round-trip latency, validating 30 assets burns a significant amount of time even though nothing changed. Even with HTTP/2 multiplexing them in parallel, at least one round trip remains.

So conditional requests serve a different purpose. They are good for reducing transfer volume on resources that change often, and inadequate for reducing the loading time of static assets. For static assets, you have to eliminate the request itself.

There are practical problems with ETag too. When several servers serve the same file and the ETag differs per server, the user gets a 200 instead of a 304 every time they are routed to a different node. The default configuration that includes the file inode number in the ETag calculation causes this. You have to switch to a content-hash basis or remove that component.

Compression also has an effect. Encoding a response with gzip or brotli changes the bytes, so a strong ETag cannot be reused as-is. You need a weak ETag or separate values per encoding, plus Vary: Accept-Encoding.

Last-Modified has the limitation of second-level resolution. If something changes twice within the same second, it cannot tell them apart. Send both if you can, but when accuracy matters, ETag takes precedence.

Hashed filenames and immutable — the standard combination for frontend deploys

This is why bundlers produce filenames like app.4f2a1c9e.js. When the content changes the hash changes, and when the hash changes the URL changes. A different URL means a different cache key, so there is no way to collide with the old cache.

Which means this file is safe to cache forever.

# static asset with a hash in the name
Cache-Control: public, max-age=31536000, immutable

# entry point HTML
Cache-Control: no-cache

Without immutable, when the user hits refresh the browser sends conditional requests even for assets that are still fresh. With 40 assets, that is 40 round trips of 304 on every refresh. immutable skips that revalidation as well.

The core of this combination is that the set of things to invalidate shrinks to exactly one. Only the HTML is validated each time, and when the asset URLs referenced by the HTML change, the new assets are fetched automatically. It replaces the problem of cache invalidation being hard with a design that never invalidates.

Two things to watch. Putting a long max-age on the HTML collapses the whole structure, because a user holding old HTML keeps referencing the old asset URLs. And if you delete the old asset files immediately on deploy, users holding old HTML hit a 404. You have to keep the assets from the previous few versions around.

Three cache layers and three different ways to invalidate

The same response is stored in several places at once. Each layer has different controls.

The browser cache lives on the user's device. There is no way to send an invalidation command here. A URL you once shipped with a one-year max-age cannot be taken back until that user clears their cache. So a TTL you send to the browser should only be long when you are certain you will never need to take it back. Hashed filenames are what create that certainty.

The CDN cache is under our control. You can purge by URL or by tag through a purge API, and most CDNs support surrogate keys or cache tags. Change one product and you clear every page carrying that product tag in one go. It does take seconds to tens of seconds to propagate to edges worldwide.

The reverse proxy cache lives inside our own infrastructure. You clear it with the nginx cache purge, or with ban or purge in Varnish. It is flexible because you can specify ranges with regular expressions, but clearing a wide range in one shot sends a stampede to the origin.

There is a fourth layer that gets forgotten often: the service worker cache. It does not follow Cache-Control and behaves exactly as the code we wrote decides. If you deployed and only certain users keep seeing the old screen, suspect the service worker first.

You often want different TTLs per layer. Short for the browser, long for the CDN, so that a purge on deploy applies instantly while origin load stays low.

Cache-Control: public, max-age=60
CDN-Cache-Control: public, max-age=86400

CDN-Cache-Control is interpreted only by the CDN and ignored by the browser. s-maxage produces a similar effect, but that value applies to every shared cache, so it also lands on intermediate caches such as a corporate proxy. That is the difference.

Vary and cache key explosion

The cache key is basically the method and the URL. If the same URL produces different responses depending on request headers, you have to say so.

Vary: Accept-Encoding, Origin

This one is mandatory. Serving a gzip response to a client that does not support compression, or reusing a stored response whose CORS headers were meant for a different origin, both come from omitting it.

The problem is what you put in Vary, because a separate cache entry is created for every combination of values of the listed headers.

Adding Vary: User-Agent effectively turns caching off. You get an entry for every browser version and OS combination, and in real traffic that runs to tens of thousands of variants. The hit rate converges to zero. If you genuinely need different responses by device type, normalize the UA at the edge into a small set of values such as mobile and desktop, and split on that value instead.

Vary: Cookie is more dangerous. A single cookie planted by an analytics tool is enough to make the value differ per user, so you get as many entries as you have users. Caching being turned off is the good outcome; the storage explodes and pushes other content out. Most CDNs have a feature to whitelist only the cookies that go into the cache key, so use it.

Vary: Accept-Language also produces more entries than you expect, because language negotiation headers come in many combinations. Putting the language in the path and distinguishing by URL is friendlier to the cache.

stale-while-revalidate and the traps in caching API responses

When users hit an expired cache, they have to wait for the origin response. There is also the problem that at the instant a popular page expires, every request arriving at once goes to the origin.

Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400

It is fresh for 60 seconds. For the following 600 seconds it returns the cached response immediately while fetching and storing a new one in the background. From the user's point of view it is always cache-hit speed, and the content is at most a little over 60 seconds old. stale-if-error returns the old response for a whole day when the origin serves a 5xx or is unreachable. It is a cheap way to hide downtime from users.

This combination fits list pages, home screens, and the read responses of public APIs — places that do not need second-level freshness. Conversely, do not use it for data such as stock counts or balances, where a stale value is immediately an error.

Leaking an authenticated response into a shared cache

The biggest accident in API response caching is not performance but data exposure.

As a rule, a shared cache does not store the response to a request carrying an Authorization header. Not unless you explicitly attach public or s-maxage. But this protection does not apply to requests authenticated with cookies. If a cookie-login site puts Cache-Control: max-age=300 on per-user HTML, the CDN stores user A's page and hands it straight to user B. This has happened at several services and is the kind of thing whose cause is easy to identify too late.

The defence is simple. Always attach private to per-user responses, and go to no-store for anything sensitive.

# per-user response
Cache-Control: private, no-store

# public list response
Cache-Control: public, max-age=30, stale-while-revalidate=300

The better approach is to separate the paths in the first place. Split public data and per-user data into different endpoints, and you can put the public side on the CDN with a clear conscience and turn caching off on the per-user side. Mix the two in one response and the whole thing has to follow the most conservative policy.

Attaching an ETag to an API to reduce client polling cost is useful too, but when a conditional request arrives the server still has to compute the ETag. It only pays off when there is a cheap basis for it, such as an updated-at column or a version counter. Building the whole response, hashing it, and then returning a 304 saves bandwidth only, leaving server load untouched.

Closing — if invalidation is hard, design so you never invalidate

The saying that cache invalidation is hard is not an exaggeration. You cannot send commands to the browser cache, CDN purges take time to propagate, each layer has a different method, and the service worker does not follow the rules. So well-built systems reduce the occasions for invalidation instead of making invalidation more sophisticated.

Make the URL change when the content changes, and cache that URL forever. Funnel everything that changes into a single entry point validated on a short cycle. Serve anything that does not need second-level freshness with stale-while-revalidate and no latency. Separate per-user data at the path level so cache policies never mix.

The first thing to decide in caching design is not a TTL number but whether there is any chance you will need to take this response back later. Build a structure where you can be certain you never will, and the rest of the settings follow on their own.

현재 단락 (1/73)

The deploy is finished, the new files are on the server, and an incognito window shows the new scree...

작성 글자: 0원문 글자: 11,257작성 단락: 0/73