Skip to content

Split View: HTTP 캐싱 제대로 쓰기 — Cache-Control, ETag, stale-while-revalidate의 정확한 의미

✨ Learn with Quiz
|

HTTP 캐싱 제대로 쓰기 — Cache-Control, ETag, stale-while-revalidate의 정확한 의미

들어가며 — 배포했는데 사용자는 옛날 번들을 본다

배포가 끝났고, 서버에는 새 파일이 올라가 있고, 시크릿 창에서는 새 화면이 나옵니다. 그런데 일부 사용자는 여전히 옛날 화면을 봅니다. 강력 새로고침을 안내하면 해결되지만, 그 안내가 필요한 시점에 이미 캐싱 설계가 잘못됐다는 뜻입니다.

HTTP 캐싱은 지시어 몇 개를 외우는 문제가 아니라, 어느 계층이 무엇을 얼마나 오래 들고 있고 그것을 어떻게 되돌릴 수 있는지를 아는 문제입니다. 그리고 되돌릴 수 없는 계층이 하나 있습니다. 사용자의 브라우저입니다.

Cache-Control 지시어의 정확한 의미

가장 많이 오해되는 지시어부터 정리합니다.

지시어정확한 의미흔한 오해
max-age=N응답 생성 시점부터 N초간 신선. 신선한 동안 원 서버에 묻지 않음브라우저가 N초마다 확인한다고 생각
s-maxage=N공유 캐시에만 적용되며 그곳에서 max-age를 덮어씀브라우저에도 적용된다고 생각
no-cache저장은 하되 재사용 전에 반드시 원 서버에 검증캐시하지 말라는 뜻으로 이해
no-store어떤 캐시에도 저장 금지. 디스크에도 남기지 않음no-cache와 같은 것으로 이해
must-revalidate신선도가 끝난 뒤에는 검증 없이 제공 금지. 오류 시 stale 제공도 금지항상 검증하라는 뜻으로 이해
private브라우저 등 단일 사용자 캐시에만 저장 가능암호화나 접근 제어로 이해
public평소라면 저장되지 않을 응답도 공유 캐시에 저장 가능하다고 표시캐싱을 켜는 스위치로 이해
immutable신선한 동안에는 새로고침에도 재검증하지 않음영원히 캐시된다고 이해
stale-while-revalidate=N만료 후 N초간 stale 응답을 즉시 주고 백그라운드로 갱신max-age를 늘리는 것과 같다고 이해

no-cacheno-store의 차이가 결정적입니다. 로그인한 사용자의 계좌 페이지에 no-cache를 붙이면 응답은 디스크에 저장됩니다. 공용 PC에서 뒤로 가기를 누르거나 캐시 디렉터리를 뒤지면 내용이 남아 있습니다. 저장 자체를 막으려면 no-store여야 합니다.

반대로 자주 보는 것이 이 조합입니다.

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

no-store가 있으면 저장이 안 되므로 나머지는 의미가 없습니다. must-revalidate는 저장된 응답에 대한 규칙이고, Pragma는 HTTP/1.0 시절의 요청 헤더라 응답에 붙여도 현대 캐시는 보지 않습니다. 관행처럼 복사되어 다니지만 Cache-Control: no-store 한 줄이면 충분합니다.

또 하나 알아야 할 것은 아무 캐시 헤더도 안 보내는 것이 캐시 금지를 뜻하지 않는다는 점입니다. 명시적 신선도 정보가 없으면 캐시는 휴리스틱으로 수명을 추정할 수 있고, 관행적으로 Last-Modified로부터 경과한 시간의 10퍼센트 정도를 씁니다. 한 달 전에 수정된 파일이라면 3일가량 캐시될 수 있다는 뜻입니다. "캐시 설정을 안 했으니 캐시되지 않을 것"이라는 가정은 성립하지 않습니다.

조건부 요청과 304 — 절약하는 것과 절약하지 못하는 것

신선도가 끝난 응답을 캐시가 버리지는 않습니다. 검증자를 들고 원 서버에 물어봅니다.

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

본문이 없습니다. 12KB짜리 SVG를 다시 받지 않았으니 대역폭을 아꼈습니다.

여기가 중요한 지점입니다. 304는 대역폭을 아끼지만 왕복 시간은 그대로 듭니다. 왕복 지연이 180밀리초인 모바일 회선에서 자산 30개를 검증하면, 아무것도 바뀌지 않았는데도 상당한 시간을 씁니다. HTTP/2에서 다중화되어 병렬로 처리되더라도 최소 한 번의 왕복은 남습니다.

그래서 조건부 요청은 목적이 다릅니다. 자주 바뀌는 리소스에서 전송량을 줄이는 데는 좋고, 정적 자산의 로딩 시간을 줄이는 데는 부족합니다. 정적 자산은 요청 자체를 없애야 합니다.

ETag를 쓸 때 실무에서 걸리는 문제도 있습니다. 여러 대의 서버가 같은 파일을 서빙하는데 ETag가 서버마다 다르면, 사용자가 다른 노드로 라우팅될 때마다 304가 아니라 200이 돌아옵니다. 파일의 inode 번호를 ETag 계산에 포함하는 기본 설정이 이 문제를 일으킵니다. 내용 해시 기반으로 바꾸거나 해당 요소를 빼야 합니다.

압축도 영향을 줍니다. 응답을 gzip이나 brotli로 인코딩하면 바이트가 달라지므로 강한 ETag를 그대로 쓰면 안 됩니다. 약한 ETag를 쓰거나 인코딩별로 값을 분리해야 하고, Vary: Accept-Encoding도 함께 필요합니다.

Last-Modified는 초 단위 해상도라는 한계가 있습니다. 같은 초에 두 번 바뀌면 구분하지 못합니다. 둘 다 보낼 수 있으면 보내되, 정확성이 필요하면 ETag가 우선입니다.

해시 파일명과 immutable — 프런트엔드 배포의 표준 조합

번들러가 app.4f2a1c9e.js 같은 파일명을 만드는 이유가 여기 있습니다. 내용이 바뀌면 해시가 바뀌고, 해시가 바뀌면 URL이 바뀝니다. URL이 다르면 캐시 키가 다르므로 옛 캐시와 충돌할 일이 없습니다.

그러면 이 파일은 영원히 캐시해도 안전합니다.

# 해시가 들어간 정적 자산
Cache-Control: public, max-age=31536000, immutable

# 진입점 HTML
Cache-Control: no-cache

immutable이 없으면 사용자가 새로고침을 눌렀을 때 브라우저는 신선한 자산에 대해서도 조건부 요청을 보냅니다. 자산이 40개면 새로고침마다 304가 40개 오갑니다. immutable은 그 재검증까지 생략시킵니다.

이 조합의 핵심은 무효화 대상이 딱 하나로 줄어든다는 것입니다. HTML만 매번 검증하고, HTML이 참조하는 자산 URL이 바뀌면 새 자산이 자동으로 받아집니다. 캐시 무효화가 어렵다는 문제를 무효화하지 않는 설계로 치환한 것입니다.

주의할 점이 두 가지 있습니다. HTML에 긴 max-age를 붙이면 이 구조가 통째로 무너집니다. 사용자가 옛 HTML을 들고 있으면 옛 자산 URL을 계속 참조하기 때문입니다. 그리고 배포할 때 옛 자산 파일을 즉시 지우면, 옛 HTML을 들고 있는 사용자가 404를 만납니다. 이전 몇 개 버전의 자산은 남겨 두어야 합니다.

세 개의 캐시 계층과 서로 다른 무효화 방법

같은 응답이 여러 곳에 동시에 저장됩니다. 각 계층은 통제 수단이 다릅니다.

브라우저 캐시는 사용자의 기기에 있습니다. 여기에는 무효화 명령을 보낼 방법이 없습니다. 한 번 max-age를 1년으로 내보낸 URL은 그 사용자가 캐시를 지우기 전까지 되돌릴 수 없습니다. 그래서 브라우저에 내보내는 TTL은 되돌릴 필요가 없다고 확신하는 경우에만 길게 잡습니다. 해시 파일명이 그 확신을 만들어 줍니다.

CDN 캐시는 우리 통제 아래 있습니다. 퍼지 API로 URL이나 태그 단위로 지울 수 있고, 대부분의 CDN이 서로게이트 키나 캐시 태그를 지원합니다. 상품 하나가 바뀌면 그 상품 태그가 붙은 모든 페이지를 한 번에 지우는 식입니다. 다만 전 세계 엣지에 반영되는 데 수 초에서 수십 초가 걸립니다.

리버스 프록시 캐시는 우리 인프라 안에 있습니다. nginx의 캐시 퍼지, Varnish의 ban이나 purge로 지웁니다. 정규식으로 범위를 지정할 수 있어 유연하지만, 넓은 범위를 한 번에 지우면 원 서버로 요청이 몰립니다.

네 번째 계층이 하나 더 있는데 자주 잊힙니다. 서비스 워커 캐시입니다. 이것은 Cache-Control을 따르지 않고 우리가 작성한 코드가 정한 대로 동작합니다. 배포했는데 특정 사용자만 계속 옛 화면을 본다면 서비스 워커를 먼저 의심해야 합니다.

계층별로 다른 TTL을 주고 싶을 때가 많습니다. 브라우저에는 짧게, CDN에는 길게 주면 배포 시 퍼지로 즉시 반영하면서 원 서버 부하는 낮게 유지할 수 있습니다.

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

CDN-Cache-Control은 CDN만 해석하고 브라우저는 무시합니다. s-maxage로도 비슷한 효과를 내지만, 그 값은 모든 공유 캐시에 적용되므로 회사 프록시 같은 중간 캐시에도 함께 걸린다는 차이가 있습니다.

Vary와 캐시 키 폭발

캐시 키는 기본적으로 메서드와 URL입니다. 같은 URL인데 요청 헤더에 따라 응답이 달라진다면 그 사실을 알려야 합니다.

Vary: Accept-Encoding, Origin

이것은 필수입니다. gzip 응답이 압축을 지원하지 않는 클라이언트에게 나가거나, CORS 헤더가 다른 오리진용으로 저장된 채 재사용되는 사고가 여기서 나옵니다.

문제는 Vary에 무엇을 넣느냐입니다. 나열한 헤더의 값 조합마다 별도의 캐시 항목이 생기기 때문입니다.

Vary: User-Agent를 붙이면 사실상 캐시가 꺼집니다. 브라우저 버전과 OS 조합만큼 항목이 생기고, 실제 트래픽에서 그 종류는 수만 가지입니다. 적중률이 0에 수렴합니다. 기기 종류에 따라 다른 응답이 필요하다면 엣지에서 UA를 모바일과 데스크톱 같은 소수의 값으로 정규화한 뒤 그 값을 기준으로 나누어야 합니다.

Vary: Cookie는 더 위험합니다. 분석 도구가 심어 놓은 쿠키 하나만 있어도 사용자마다 값이 달라지므로 항목이 사용자 수만큼 생깁니다. 캐시가 꺼지는 정도면 다행이고, 저장 공간이 폭발해서 다른 콘텐츠까지 밀려납니다. 캐시 키에 넣을 쿠키만 화이트리스트로 지정하는 기능이 대부분의 CDN에 있으니 그것을 씁니다.

Vary: Accept-Language도 언어 협상 헤더의 조합이 다양해서 생각보다 항목이 많이 생깁니다. 경로에 언어를 넣어 URL로 구분하는 편이 캐시 친화적입니다.

stale-while-revalidate와 API 응답 캐싱의 함정

만료된 캐시를 만나면 사용자는 원 서버 응답을 기다려야 합니다. 인기 있는 페이지의 캐시가 만료되는 순간 동시에 들어온 요청들이 전부 원 서버로 가는 문제도 있습니다.

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

60초 동안은 신선합니다. 그 이후 600초 동안은 캐시된 응답을 즉시 돌려주면서 뒤에서 새 응답을 받아 저장합니다. 사용자 입장에서는 항상 캐시 적중 속도이고, 콘텐츠는 최대 60초 남짓 오래됐을 뿐입니다. stale-if-error는 원 서버가 5xx를 내거나 도달 불가일 때 하루 동안 옛 응답이라도 돌려줍니다. 장애 시간을 사용자에게 감추는 값싼 방법입니다.

이 조합은 목록 페이지, 홈 화면, 공개 API의 읽기 응답처럼 초 단위 신선도가 필요 없는 곳에 잘 맞습니다. 반대로 재고 수량이나 잔액처럼 오래된 값이 곧 오류가 되는 데이터에는 쓰면 안 됩니다.

인증된 응답을 공유 캐시에 흘리는 사고

API 응답 캐싱에서 가장 큰 사고는 성능이 아니라 데이터 노출입니다.

Authorization 헤더가 실린 요청의 응답은 공유 캐시가 저장하지 않는 것이 원칙입니다. 명시적으로 public이나 s-maxage를 붙이지 않는 한 그렇습니다. 그런데 쿠키로 인증하는 요청에는 이 보호가 적용되지 않습니다. 쿠키 기반 로그인 사이트가 사용자별 HTML에 Cache-Control: max-age=300을 붙이면, CDN이 사용자 A의 페이지를 저장했다가 사용자 B에게 그대로 돌려줍니다. 실제로 여러 서비스에서 발생했고 원인 파악이 늦어지기 쉬운 유형입니다.

방어는 단순합니다. 사용자별 응답에는 반드시 private을 붙이고, 민감한 것은 no-store로 갑니다.

# 사용자별 응답
Cache-Control: private, no-store

# 공개 목록 응답
Cache-Control: public, max-age=30, stale-while-revalidate=300

더 나은 방법은 애초에 경로를 분리하는 것입니다. 공개 데이터와 사용자별 데이터를 다른 엔드포인트로 나누면, 공개 쪽은 마음 놓고 CDN에 태우고 사용자별 쪽은 캐시를 끄면 됩니다. 한 응답 안에 둘을 섞으면 전체를 가장 보수적인 정책에 맞춰야 합니다.

API에 ETag를 붙여 클라이언트의 폴링 비용을 줄이는 것도 유용하지만, 조건부 요청이 오면 서버는 여전히 ETag를 계산해야 합니다. 갱신 시각 컬럼이나 버전 카운터처럼 값싸게 얻을 수 있는 근거가 있을 때만 실익이 있습니다. 전체 응답을 만들어서 해시를 뜬 뒤 304를 돌려주면 대역폭만 아끼고 서버 부하는 그대로입니다.

마치며 — 무효화가 어려우면 무효화하지 않는 설계로

캐시 무효화가 어렵다는 말은 과장이 아닙니다. 브라우저 캐시에는 명령을 보낼 수 없고, CDN 퍼지는 전파에 시간이 걸리고, 계층마다 방법이 다르고, 서비스 워커는 규칙을 따르지 않습니다. 그래서 잘 만든 시스템은 무효화를 정교하게 하는 대신 무효화할 일을 줄입니다.

내용이 바뀌면 URL이 바뀌게 만들고, 그 URL은 영원히 캐시합니다. 바뀌는 것은 짧게 검증하는 진입점 하나로 모읍니다. 초 단위 신선도가 필요 없는 것은 stale-while-revalidate로 지연 없이 서빙합니다. 사용자별 데이터는 경로부터 분리해서 캐시 정책이 섞이지 않게 합니다.

캐싱 설계에서 먼저 정할 것은 TTL 숫자가 아니라 이 응답을 나중에 되돌려야 할 가능성이 있는가입니다. 되돌릴 일이 없다고 확신할 수 있는 구조를 만들면, 나머지 설정은 저절로 따라옵니다.

Using HTTP Caching Properly — What Cache-Control, ETag, and stale-while-revalidate Actually Mean

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.