Split View: CDN & Edge 캐싱 전략 완전 가이드 2025: Cache Invalidation, ETag, Stale-While-Revalidate
CDN & Edge 캐싱 전략 완전 가이드 2025: Cache Invalidation, ETag, Stale-While-Revalidate
TL;DR
- 컴퓨터 과학의 2가지 어려운 문제: cache invalidation, naming things, off-by-one errors (Phil Karlton)
- HTTP 캐시 헤더 마스터:
Cache-Control,ETag,Vary,If-None-Match - CDN 캐시 = 전 세계 분산 캐시: 사용자에게 가까운 PoP에서 응답 → 50ms 이하 latency
- Stale-While-Revalidate (SWR): 오래된 응답 즉시 반환 + 백그라운드 갱신 = 100% 캐시 히트율 체감
- Edge Computing: 캐시를 넘어 코드 실행 → Cloudflare Workers, Fastly Compute@Edge
1. 캐시는 왜 어려운가?
1.1 Phil Karlton의 명언
"There are only two hard things in Computer Science: cache invalidation and naming things."
이 농담은 반은 진실입니다. 캐시 무효화는 정말 어렵습니다.
1.2 두 가지 본질적 어려움
1. 언제 무효화할 것인가?
- 너무 빨리 → 캐시 효과 없음
- 너무 늦게 → stale data 문제
2. 어떻게 무효화할 것인가?
- 단일 키? 패턴? 전체?
- 동기? 비동기?
- 실패 시 어떻게?
1.3 캐시 일관성의 트레이드오프
강한 일관성 ←─────────────────→ 높은 성능
(실시간) (캐시 적중)
모든 시스템은 이 스펙트럼의 어딘가에 있습니다. 100% 둘 다 가질 수 없습니다.
2. HTTP 캐시 헤더 마스터하기
2.1 Cache-Control — 캐시의 핵심
Cache-Control: public, max-age=3600, s-maxage=86400
디렉티브:
| 값 | 의미 |
|---|---|
public | CDN, 브라우저 모두 캐시 가능 |
private | 브라우저만 (사용자별) |
no-cache | 캐시는 가능, 매번 검증 필요 |
no-store | 절대 캐시 안 됨 (민감 데이터) |
max-age=N | 브라우저 캐시 유효 시간 (초) |
s-maxage=N | CDN 캐시 유효 시간 (브라우저보다 우선) |
must-revalidate | 만료 후 반드시 검증 |
immutable | 절대 변하지 않음 (CSS, JS with hash) |
stale-while-revalidate=N | 만료 후 N초 동안 stale 사용 가능 |
stale-if-error=N | 에러 시 N초 동안 stale 사용 |
2.2 일반적 패턴
HTML (자주 변함):
Cache-Control: public, max-age=0, must-revalidate
API JSON (변할 수 있음):
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400
정적 파일 (해시 포함):
Cache-Control: public, max-age=31536000, immutable
민감 데이터 (사용자 정보):
Cache-Control: private, no-store
2.3 ETag — 효율적 검증
서버가 콘텐츠의 고유 식별자(해시)를 응답:
HTTP/1.1 200 OK
ETag: "abc123"
Cache-Control: max-age=3600
캐시 만료 후 클라이언트:
GET /api/users/123 HTTP/1.1
If-None-Match: "abc123"
서버가 콘텐츠가 같으면:
HTTP/1.1 304 Not Modified
ETag: "abc123"
304 Not Modified = body 없음. 대역폭 절약.
2.4 Last-Modified — 시간 기반
HTTP/1.1 200 OK
Last-Modified: Tue, 15 Apr 2025 10:00:00 GMT
GET /api/users/123 HTTP/1.1
If-Modified-Since: Tue, 15 Apr 2025 10:00:00 GMT
ETag보다 약함: 1초 이내 변경 감지 못 함, 시계 동기화 의존.
2.5 Vary — 캐시의 동일성 정의
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Vary: Accept-Encoding, Accept-Language
의미: "같은 URL이라도 Accept-Encoding(gzip vs br)과 Accept-Language(en vs ko)가 다르면 다른 캐시".
주의: Vary: User-Agent는 거의 무한 변형 → 캐시 무용지물. 절대 하지 마세요.
2.6 일반적 실수
1. no-cache ≠ "캐시 안 됨"
no-cache: 캐시는 가능, 사용 전 검증 필요no-store: 캐시 절대 X
2. Pragma: no-cache는 옛날 헤더
- HTTP/1.0 잔재
- 거의 무시되지만 호환성 위해 유지하는 곳 있음
3. Vary: *는 캐시 비활성화
- 모든 헤더가 다르면 다른 캐시 = 절대 hit 안 함
3. CDN 동작 원리
3.1 CDN이란?
Content Delivery Network — 전 세계에 분산된 캐시 서버 네트워크.
사용자 (서울) → [CDN PoP 서울] (캐시 hit!) → 응답
↓ miss
[Origin 서버 (미국)]
효과:
- 서울 사용자: 5ms (CDN PoP)
- 미국 origin 직접: 200ms (왕복 + 처리)
3.2 CDN의 구성 요소
| 컴포넌트 | 역할 |
|---|---|
| PoP (Point of Presence) | 도시별 캐시 서버 (Cloudflare는 300+) |
| Origin | 원본 서버 (당신의 서버) |
| Edge Cache | PoP의 캐시 저장소 |
| DNS | 가장 가까운 PoP로 라우팅 |
| Anycast | 같은 IP가 여러 PoP로 라우팅 |
3.3 캐시 키
CDN은 무엇으로 캐시를 식별하는가?
기본: URL만.
GET /api/users/123 → 캐시
GET /api/users/123?lang=en → 다른 캐시 (query string 포함)
Cloudflare Workers:
const cacheKey = new Request(url, { method: 'GET' })
const cache = caches.default
const cached = await cache.match(cacheKey)
3.4 cache hit ratio
가장 중요한 메트릭.
hit_ratio = cache_hits / (cache_hits + cache_misses)
| Hit Ratio | 의미 |
|---|---|
| 95%+ | 우수 |
| 90-95% | 좋음 |
| 80-90% | 개선 여지 |
| 80% 미만 | 캐시 전략 재검토 필요 |
1% 향상이 큰 의미: 96% → 97% = origin 트래픽 25% 감소.
3.5 CDN 비교
| Cloudflare | Fastly | CloudFront | Akamai | Vercel/Netlify | |
|---|---|---|---|---|---|
| PoP 수 | 300+ | 80+ | 600+ | 4000+ | 100+ |
| Edge 컴퓨팅 | Workers (V8) | Compute@Edge (Wasm) | Lambda@Edge | EdgeWorkers | Edge Functions |
| 무료 티어 | ✅ 관대 | ❌ | ❌ | ❌ | ✅ |
| 가격 | 매우 저렴 | 비쌈 | 보통 | 비쌈 | 보통 |
| 캐시 무효화 | 즉시 | 150ms (Instant Purge) | 분 단위 | 분 단위 | 분 단위 |
| DDoS 보호 | 우수 | 좋음 | 좋음 | 우수 | 보통 |
Cloudflare: 가성비 챔피언. 90% 사용 사례에 적합. Fastly: 빠른 무효화, 미디어/뉴스에 강함. CloudFront: AWS 통합이 강점. Akamai: 엔터프라이즈, 가장 많은 PoP.
4. Cache Invalidation — 가장 어려운 부분
4.1 4가지 무효화 전략
1. TTL 기반: 시간이 지나면 자동 만료
Cache-Control: max-age=3600 // 1시간 후 만료
장점: 단순. 단점: 1시간 동안 stale 가능.
2. Push (즉시 무효화): 콘텐츠 변경 시 CDN에 알림
curl -X POST https://api.cloudflare.com/zones/.../purge_cache \
-H "Authorization: Bearer ..." \
-d '{"files":["https://example.com/api/users/123"]}'
장점: 즉각적. 단점: 복잡, 비용.
3. ETag 기반: 매 요청마다 검증 (304 응답)
- 대역폭은 절약, 지연은 여전
4. Versioned URLs: 변경 시 새 URL
/static/main.abc123.js → /static/main.def456.js
장점: 무효화 자체가 불필요. CSS/JS에 표준.
4.2 Cloudflare의 무효화 API
# 단일 URL
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-d '{"files":["https://example.com/page1"]}'
# 태그 기반 (Enterprise)
curl -X POST ... -d '{"tags":["user-123"]}'
# 전체
curl -X POST ... -d '{"purge_everything":true}'
4.3 Cache Tags — 우아한 무효화
Fastly와 Cloudflare Enterprise가 지원:
HTTP/1.1 200 OK
Cache-Tag: user-123, post-456, blog-list
무효화:
curl -X POST https://api.fastly.com/service/{id}/purge/user-123
→ user-123 태그가 붙은 모든 캐시 즉시 무효화.
사용 예시: 사용자가 프로필 업데이트 → user-123 태그가 붙은 모든 페이지 무효화.
4.4 무효화의 어려운 시나리오
시나리오 1: 사용자가 댓글 작성
영향받는 페이지:
- /post/123 (해당 글)
- /post/123/comments
- /user/abc/comments (작성자 페이지)
- /sidebar/recent-comments
- ... 그리고 더 많은
→ 모든 페이지를 일일이 무효화? 태그 기반이 답.
시나리오 2: 데이터베이스 일관성
DB 트랜잭션 commit → 캐시 무효화
↓ 만약 캐시 무효화가 실패하면?
stale data!
→ 재시도 + dead letter queue. 또는 eventual consistency를 받아들이기.
시나리오 3: 무효화 폭증
블랙 프라이데이 = 가격 자주 변경 = 무효화 폭증 → CDN API rate limit.
→ 배치 무효화, 태그 사용, TTL 줄이기.
5. Stale-While-Revalidate — 게임 체인저
5.1 문제
전통적 캐시:
[캐시 만료] → [origin 요청] → [기다림] → [응답]
↑ 사용자가 기다림 (slow!)
5.2 Stale-While-Revalidate
Cache-Control: max-age=60, stale-while-revalidate=86400
의미:
- 60초간: 신선한 캐시 사용
- 60초 ~ 86460초: stale 캐시 즉시 반환 + 백그라운드에서 갱신
- 86460초 후: 만료 (origin 요청)
5.3 사용자 경험
사용자 1 (60초): [캐시 hit, fresh] instant ✅
사용자 2 (61초): [캐시 hit, stale] instant ✅ (백그라운드 갱신 시작)
사용자 3 (62초): [캐시 hit, fresh] instant ✅ (방금 갱신됨)
모든 사용자가 instant 응답! origin은 가끔만 호출됨.
5.4 Next.js의 ISR (Incremental Static Regeneration)
export async function getStaticProps() {
const data = await fetchData()
return {
props: { data },
revalidate: 60 // 60초 후 백그라운드 재생성
}
}
내부적으로 SWR 패턴 사용. 정적 속도 + 동적 데이터.
5.5 stale-if-error
Cache-Control: max-age=60, stale-if-error=86400
의미: origin 에러 시 24시간 동안 stale 데이터 반환.
효과: origin 다운에도 사이트 살아있음. 장애 회복력 핵심.
6. Cache 패턴 — 코드 레벨
6.1 Cache-Aside (Lazy Loading)
def get_user(user_id):
user = cache.get(f"user:{user_id}")
if user is None:
user = db.query(f"SELECT * FROM users WHERE id={user_id}")
cache.set(f"user:{user_id}", user, ttl=3600)
return user
가장 흔함. 단순, 안정적. 단점: cache miss 시 첫 요청은 느림.
6.2 Read-Through
캐시가 직접 origin을 호출 (애플리케이션은 캐시만 호출).
# 캐시 라이브러리가 자동 처리
user = cache.get(f"user:{user_id}", loader=lambda: db.query(...))
장점: 코드 단순. 단점: 캐시 라이브러리 의존.
6.3 Write-Through
쓰기 시 캐시도 동시 업데이트.
def update_user(user_id, data):
db.execute(f"UPDATE users SET ... WHERE id={user_id}")
cache.set(f"user:{user_id}", data, ttl=3600)
장점: 캐시 항상 최신. 단점: 쓰기 느림, 캐시 활용 안 될 데이터도 캐싱.
6.4 Write-Behind (Write-Back)
쓰기를 캐시에만 → 비동기로 DB에 반영.
def update_user(user_id, data):
cache.set(f"user:{user_id}", data, ttl=3600)
queue.push({"action": "update", "id": user_id, "data": data})
장점: 매우 빠른 쓰기. 단점: 캐시 장애 시 데이터 손실 위험.
6.5 Cache Stampede 방지
문제: 인기 키가 만료되면 수천 요청이 동시에 origin을 호출.
해결:
1. Mutex (분산 락)
def get_user(user_id):
user = cache.get(f"user:{user_id}")
if user is None:
with cache.lock(f"lock:user:{user_id}", timeout=10):
user = cache.get(f"user:{user_id}") # double-check
if user is None:
user = db.query(...)
cache.set(f"user:{user_id}", user, ttl=3600)
return user
2. Probabilistic Early Expiration TTL 직전부터 일부 요청은 미리 갱신:
def get_with_recompute(key):
value, ttl = cache.get_with_ttl(key)
if random() < ttl_factor(ttl):
# 일부 요청만 미리 재계산
recompute_async(key)
return value
3. Stale-While-Revalidate (이미 본 것)
7. Edge Computing — 캐시를 넘어서
7.1 Edge에서 코드 실행
CDN은 더 이상 단순 캐시가 아닙니다. 코드도 실행:
- Cloudflare Workers (V8 isolate)
- Fastly Compute@Edge (WebAssembly)
- AWS Lambda@Edge (Node.js, Python)
- Vercel Edge Functions (V8)
- Deno Deploy (V8)
7.2 사용 사례
1. A/B 테스팅
export default {
async fetch(request) {
const variant = Math.random() < 0.5 ? 'a' : 'b'
return fetch(`https://origin.com/page-${variant}`)
}
}
2. Geo 라우팅
const country = request.cf.country
const origin = country === 'KR' ? 'asia.api.com' : 'us.api.com'
return fetch(origin + new URL(request.url).pathname)
3. 인증/인가
const token = request.headers.get('Authorization')
const user = await verifyJWT(token)
if (!user) return new Response('Unauthorized', { status: 401 })
return fetch(origin)
4. HTML 변환
const response = await fetch(origin)
return new HTMLRewriter()
.on('h1', { element(el) { el.setInnerContent('Modified!') } })
.transform(response)
5. API 집계
const [user, posts] = await Promise.all([
fetch('https://api.com/user/123').then(r => r.json()),
fetch('https://api.com/posts?user=123').then(r => r.json())
])
return new Response(JSON.stringify({ user, posts }))
7.3 Edge 컴퓨팅의 한계
- CPU 시간 제한: 보통 10-50ms (Cloudflare Workers는 50ms)
- 메모리 제한: 128MB (Cloudflare), 50MB (Fastly)
- 콜드 스타트: V8 isolate는 거의 없음 (5ms 이하)
- 데이터베이스 접근: edge에서 origin DB는 느림 → Edge DB 필요 (Turso, D1, Neon)
7.4 Edge 데이터 저장소
| Cloudflare | Fastly | Vercel | |
|---|---|---|---|
| KV | Workers KV | Object Store | KV |
| DB | D1 (SQLite) | - | Neon, Turso |
| Object | R2 | - | Blob |
| Cache | Cache API | Cache | - |
| Vector | Vectorize | - | - |
8. CDN 캐시 베스트 프랙티스
8.1 정적 자산
# CSS, JS (해시 파일명)
Cache-Control: public, max-age=31536000, immutable
# 이미지 (잘 안 변함)
Cache-Control: public, max-age=86400, stale-while-revalidate=604800
8.2 HTML
# 자주 변하지만 캐시 가능
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
max-age=0: 브라우저는 매번 검증s-maxage=300: CDN은 5분 캐시stale-while-revalidate=86400: 24시간 동안 stale 사용
8.3 API JSON
# 사용자 별도 데이터 (private)
Cache-Control: private, max-age=60, must-revalidate
# 공개 API (public)
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400
8.4 사용자 별 콘텐츠
# 절대 캐시 안 됨
Cache-Control: private, no-store
또는 cache key에 Cookie 포함 (위험 — 카디널리티 폭증).
8.5 압축 + 캐시
HTTP/1.1 200 OK
Content-Encoding: gzip
Cache-Control: public, max-age=3600
Vary: Accept-Encoding
Vary: Accept-Encoding 필수 — gzip vs br vs none이 다른 캐시.
9. 모니터링과 디버깅
9.1 응답 헤더 확인
curl -I https://example.com/api/users/123
HTTP/2 200
cf-cache-status: HIT
age: 234
cache-control: public, max-age=3600
주요 헤더:
cf-cache-status: HIT, MISS, EXPIRED, BYPASS, REVALIDATEDage: 캐시된 지 N초x-cache: AWS CloudFront 헤더
9.2 캐시 상태
HIT ← 캐시에서 반환
MISS ← origin 요청
EXPIRED ← 만료, 갱신 중
REVALIDATED ← 304로 검증됨
BYPASS ← 캐시 우회 (no-cache 등)
DYNAMIC ← 캐시 못 함
9.3 캐시 히트율 추적
대부분 CDN 대시보드에 표시:
- Cloudflare Analytics
- Fastly Insights
- CloudFront Reports
9.4 디버깅 흐름
캐시 hit 안 됨 → 다음을 확인:
- Cache-Control 헤더:
no-cache,private있나? - Set-Cookie: 쿠키 있으면 캐시 안 됨
- Vary: 너무 많은 변형
- Method: POST는 캐시 안 됨 (대부분)
- Status code: 200, 301, 302, 404, 410만 캐시
- Query string: CDN이 query string 무시 설정?
10. 실전 — 블로그 사이트 캐싱
10.1 시나리오
- WordPress 또는 Next.js 블로그
- 일일 100만 페이지뷰
- 게시물이 자주 업데이트됨
- 댓글 시스템
10.2 캐시 전략
HTML 페이지:
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400
정적 자산:
Cache-Control: public, max-age=31536000, immutable
API JSON:
Cache-Control: public, max-age=300, stale-while-revalidate=86400
10.3 무효화
게시물 업데이트 시:
- 태그 무효화:
post-{id},category-{slug},homepage - 새 URL: 정적 자산은 해시 변경
// Next.js + Cloudflare
async function updatePost(id, data) {
await db.update(...)
await cf.purgeByTags([`post-${id}`, 'homepage'])
}
10.4 결과
- Cache hit ratio: 95%+
- Origin requests: 5만/일 (95% 절감)
- 응답 시간: 50ms (CDN) vs 500ms (origin)
- 비용: 95% 절감
퀴즈
1. no-cache와 no-store의 차이는?
답: no-cache: 캐시 가능, 단 사용 전 반드시 origin에 검증 요청 (If-None-Match). 304 받으면 캐시 사용. 의외로 캐시 활용 가능. no-store: 캐시 절대 불가능. 요청, 응답을 어디에도 저장하지 않음 (메모리, 디스크). 민감 데이터에 사용. 흔한 실수: "캐시 안 됨"을 원할 때 no-cache를 사용 → 실제로는 캐시되고 검증만 함.
2. Stale-While-Revalidate가 게임 체인저인 이유는?
답: 사용자에게 항상 instant 응답을 줍니다. 캐시 만료 후에도 stale 데이터를 즉시 반환하고, 백그라운드에서 갱신. 사용자는 기다리지 않고, 다음 요청부터 새 데이터. 결과: 체감 cache hit ratio 100%, origin 트래픽은 여전히 적음. Next.js ISR이 이 패턴 사용. SWR은 캐시 만료의 본질적 트레이드오프(신선함 vs 속도)를 거의 해결합니다.
3. Cache Stampede 방지 방법 3가지는?
답: (1) Mutex/분산 락 — 만료된 키에 대해 한 요청만 origin을 호출, 나머지는 결과를 기다림 (또는 stale 사용), (2) Probabilistic Early Expiration — TTL 직전부터 일부 요청을 미리 재계산, (3) Stale-While-Revalidate — 만료된 캐시도 사용하면서 백그라운드 갱신. 인기 키일수록 이러한 보호가 중요. Reddit, Facebook이 비슷한 패턴 사용.
4. CDN 캐시 hit ratio가 1% 향상되면?
답: origin 트래픽이 25%+ 감소합니다. 95% → 96%면 origin 요청은 5% → 4%로 감소 = 20% 절감. 96% → 97%면 4% → 3% = 25% 절감. 캐시 히트율이 높아질수록 1%의 가치가 커집니다. 이는 (1) 서버 비용, (2) 대역폭 비용, (3) 응답 시간, (4) 신뢰성 모두에 영향. 0.5% 향상도 큰 비즈니스 가치.
5. Edge Computing이 캐시를 넘어선 의미는?
답: CDN은 더 이상 정적 파일 캐싱만 하지 않습니다. 사용자에게 가까운 노드에서 코드를 실행합니다. 사용 사례: A/B 테스팅, geo 라우팅, 인증, HTML 변환, API 집계. 결과: 동적 콘텐츠도 50ms 이하. Cloudflare Workers는 V8 isolate로 콜드 스타트 5ms 이하. Fastly Compute@Edge는 Wasm 기반. Origin 서버의 역할이 줄어들고, edge가 점점 더 많은 일을 합니다.
참고 자료
- MDN HTTP Caching
- RFC 7234 — HTTP Caching
- RFC 5861 — Stale Content — SWR 정의
- Cloudflare Cache Docs
- Fastly Caching Best Practices
- HTTP Caching Best Practices — Google
- Cache Stampede
- SWR React Library — Vercel
- Cloudflare Workers
- Fastly Compute@Edge
- Vercel Edge Functions
- HTTP Caching Decision Tree — Harry Roberts
Complete Guide to CDN & Edge Caching Strategies 2025: Cache Invalidation, ETag, Stale-While-Revalidate
TL;DR
- Two hard things in computer science: cache invalidation, naming things, off-by-one errors (Phil Karlton)
- Master HTTP cache headers:
Cache-Control,ETag,Vary,If-None-Match - CDN cache = globally distributed cache: response from the PoP closest to the user, sub-50ms latency
- Stale-While-Revalidate (SWR): instant return of stale responses while revalidating in the background — feels like 100% cache hit ratio
- Edge computing: goes beyond caching to run code — Cloudflare Workers, Fastly Compute@Edge
1. Why Is Caching Hard?
1.1 Phil Karlton's Quote
"There are only two hard things in Computer Science: cache invalidation and naming things."
The joke is half true. Cache invalidation really is hard.
1.2 Two Fundamental Difficulties
1. When do you invalidate?
- Too soon → no cache benefit
- Too late → stale data problem
2. How do you invalidate?
- Single key? Pattern? Everything?
- Synchronous? Asynchronous?
- What if it fails?
1.3 The Consistency/Performance Trade-off
Strong consistency ←─────────────────→ High performance
(real-time) (cache hit)
Every system sits somewhere on this spectrum. You cannot have both at 100%.
2. Mastering HTTP Cache Headers
2.1 Cache-Control — The Core of Caching
Cache-Control: public, max-age=3600, s-maxage=86400
Directives:
| Value | Meaning |
|---|---|
public | Both CDN and browser can cache |
private | Browser only (user-specific) |
no-cache | Cacheable, but revalidation required each time |
no-store | Never cached (sensitive data) |
max-age=N | Browser cache lifetime (seconds) |
s-maxage=N | CDN cache lifetime (overrides browser) |
must-revalidate | After expiration, must revalidate |
immutable | Never changes (CSS, JS with hash) |
stale-while-revalidate=N | Stale usable for N seconds after expiration |
stale-if-error=N | On error, stale usable for N seconds |
2.2 Common Patterns
HTML (changes frequently):
Cache-Control: public, max-age=0, must-revalidate
API JSON (can change):
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400
Static assets (with hash in filename):
Cache-Control: public, max-age=31536000, immutable
Sensitive data (user info):
Cache-Control: private, no-store
2.3 ETag — Efficient Validation
The server responds with a unique identifier (hash) for the content:
HTTP/1.1 200 OK
ETag: "abc123"
Cache-Control: max-age=3600
After cache expiry the client sends:
GET /api/users/123 HTTP/1.1
If-None-Match: "abc123"
If the content is unchanged:
HTTP/1.1 304 Not Modified
ETag: "abc123"
304 Not Modified = no body. Saves bandwidth.
2.4 Last-Modified — Time-Based
HTTP/1.1 200 OK
Last-Modified: Tue, 15 Apr 2025 10:00:00 GMT
GET /api/users/123 HTTP/1.1
If-Modified-Since: Tue, 15 Apr 2025 10:00:00 GMT
Weaker than ETag: can't detect sub-second changes, depends on clock synchronization.
2.5 Vary — Defining Cache Equivalence
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Vary: Accept-Encoding, Accept-Language
Meaning: "Even with the same URL, different Accept-Encoding (gzip vs br) or Accept-Language (en vs ko) means a different cache entry."
Warning: Vary: User-Agent yields nearly infinite variants → cache becomes useless. Never do this.
2.6 Common Mistakes
1. no-cache does NOT mean "no caching"
no-cache: Cacheable, revalidation required before useno-store: Absolutely not cached
2. Pragma: no-cache is a legacy header
- Leftover from HTTP/1.0
- Mostly ignored, though some keep it for compatibility
3. Vary: * effectively disables caching
- "Every header differs" means every entry is unique — never a hit
3. How CDNs Work
3.1 What Is a CDN?
Content Delivery Network — a network of cache servers distributed globally.
User (Seoul) → [CDN PoP Seoul] (cache hit!) → response
↓ miss
[Origin server (US)]
Impact:
- Seoul user: 5ms (CDN PoP)
- Direct to US origin: 200ms (round-trip + processing)
3.2 CDN Components
| Component | Role |
|---|---|
| PoP (Point of Presence) | City-level cache server (Cloudflare has 300+) |
| Origin | The source server (yours) |
| Edge Cache | Cache storage inside the PoP |
| DNS | Routes users to the nearest PoP |
| Anycast | One IP routes to multiple PoPs |
3.3 Cache Key
What does the CDN use to identify a cached object?
Default: URL only.
GET /api/users/123 → cache entry
GET /api/users/123?lang=en → different cache entry (query string included)
Cloudflare Workers:
const cacheKey = new Request(url, { method: 'GET' })
const cache = caches.default
const cached = await cache.match(cacheKey)
3.4 Cache Hit Ratio
The single most important metric.
hit_ratio = cache_hits / (cache_hits + cache_misses)
| Hit Ratio | Meaning |
|---|---|
| 95%+ | Excellent |
| 90–95% | Good |
| 80–90% | Room for improvement |
| Below 80% | Cache strategy needs rethinking |
1% improvement matters a lot: 96% → 97% = 25% fewer origin requests.
3.5 CDN Comparison
| Cloudflare | Fastly | CloudFront | Akamai | Vercel/Netlify | |
|---|---|---|---|---|---|
| PoPs | 300+ | 80+ | 600+ | 4000+ | 100+ |
| Edge compute | Workers (V8) | Compute@Edge (Wasm) | Lambda@Edge | EdgeWorkers | Edge Functions |
| Free tier | Generous | No | No | No | Yes |
| Price | Very cheap | Expensive | Moderate | Expensive | Moderate |
| Cache invalidation | Instant | 150ms (Instant Purge) | Minutes | Minutes | Minutes |
| DDoS protection | Excellent | Good | Good | Excellent | Moderate |
Cloudflare: the price/performance champion. Fits 90% of use cases. Fastly: fast invalidation, strong for media and news. CloudFront: strong AWS integration. Akamai: enterprise, the most PoPs.
4. Cache Invalidation — The Hardest Part
4.1 Four Invalidation Strategies
1. TTL-based: expires automatically after a while
Cache-Control: max-age=3600 // expires in 1 hour
Pros: simple. Cons: up to 1 hour of staleness.
2. Push (immediate invalidation): notify the CDN when content changes
curl -X POST https://api.cloudflare.com/zones/.../purge_cache \
-H "Authorization: Bearer ..." \
-d '{"files":["https://example.com/api/users/123"]}'
Pros: immediate. Cons: complexity, cost.
3. ETag-based: revalidate on every request (304 response)
- Saves bandwidth, still has latency
4. Versioned URLs: a new URL on every change
/static/main.abc123.js → /static/main.def456.js
Pros: no invalidation required. Standard for CSS/JS.
4.2 Cloudflare's Invalidation API
# Single URL
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-d '{"files":["https://example.com/page1"]}'
# Tag-based (Enterprise)
curl -X POST ... -d '{"tags":["user-123"]}'
# Everything
curl -X POST ... -d '{"purge_everything":true}'
4.3 Cache Tags — Elegant Invalidation
Supported by Fastly and Cloudflare Enterprise:
HTTP/1.1 200 OK
Cache-Tag: user-123, post-456, blog-list
Invalidate:
curl -X POST https://api.fastly.com/service/{id}/purge/user-123
→ Instantly invalidates every cache entry tagged user-123.
Example: user updates their profile → invalidate every page tagged user-123.
4.4 Tricky Invalidation Scenarios
Scenario 1: user posts a comment
Affected pages:
- /post/123 (the post)
- /post/123/comments
- /user/abc/comments (author page)
- /sidebar/recent-comments
- ... and many more
→ Invalidate each one by hand? Tag-based invalidation is the answer.
Scenario 2: database consistency
DB transaction commit → invalidate cache
↓ what if invalidation fails?
stale data!
→ Retry + dead letter queue. Or accept eventual consistency.
Scenario 3: invalidation storm
Black Friday = frequent price changes = invalidation storm → CDN API rate limits.
→ Batch invalidation, use tags, shorter TTL.
5. Stale-While-Revalidate — The Game Changer
5.1 The Problem
Traditional caching:
[cache expires] → [origin request] → [wait] → [response]
↑ user is waiting (slow!)
5.2 Stale-While-Revalidate
Cache-Control: max-age=60, stale-while-revalidate=86400
Meaning:
- For 60 seconds: serve fresh cache
- From 60s to 86460s: serve stale immediately + refresh in the background
- After 86460s: expired (go to origin)
5.3 User Experience
User 1 (60s): [cache hit, fresh] instant
User 2 (61s): [cache hit, stale] instant (background refresh starts)
User 3 (62s): [cache hit, fresh] instant (just refreshed)
Every user gets an instant response. Origin is called only occasionally.
5.4 Next.js ISR (Incremental Static Regeneration)
export async function getStaticProps() {
const data = await fetchData()
return {
props: { data },
revalidate: 60 // background regeneration after 60s
}
}
Internally uses the SWR pattern. Static speed with dynamic data.
5.5 stale-if-error
Cache-Control: max-age=60, stale-if-error=86400
Meaning: on origin error, serve stale for up to 24 hours.
Effect: site stays up even when origin is down. Core of resilience.
6. Cache Patterns — At the Code Level
6.1 Cache-Aside (Lazy Loading)
def get_user(user_id):
user = cache.get(f"user:{user_id}")
if user is None:
user = db.query(f"SELECT * FROM users WHERE id={user_id}")
cache.set(f"user:{user_id}", user, ttl=3600)
return user
The most common pattern. Simple and robust. Drawback: the first request on a miss is slow.
6.2 Read-Through
The cache calls origin directly (the app only talks to the cache).
# The cache library handles this automatically
user = cache.get(f"user:{user_id}", loader=lambda: db.query(...))
Pros: simple code. Cons: depends on the cache library.
6.3 Write-Through
Update the cache at the same time as the write.
def update_user(user_id, data):
db.execute(f"UPDATE users SET ... WHERE id={user_id}")
cache.set(f"user:{user_id}", data, ttl=3600)
Pros: cache always fresh. Cons: slow writes, caches data that may never be read.
6.4 Write-Behind (Write-Back)
Write to the cache only → flush to the DB asynchronously.
def update_user(user_id, data):
cache.set(f"user:{user_id}", data, ttl=3600)
queue.push({"action": "update", "id": user_id, "data": data})
Pros: very fast writes. Cons: risk of data loss if the cache fails.
6.5 Preventing Cache Stampede
Problem: when a hot key expires, thousands of requests hit origin at once.
Solutions:
1. Mutex (distributed lock)
def get_user(user_id):
user = cache.get(f"user:{user_id}")
if user is None:
with cache.lock(f"lock:user:{user_id}", timeout=10):
user = cache.get(f"user:{user_id}") # double-check
if user is None:
user = db.query(...)
cache.set(f"user:{user_id}", user, ttl=3600)
return user
2. Probabilistic Early Expiration Right before TTL, some requests refresh ahead of time:
def get_with_recompute(key):
value, ttl = cache.get_with_ttl(key)
if random() < ttl_factor(ttl):
# only some requests recompute early
recompute_async(key)
return value
3. Stale-While-Revalidate (as covered above)
7. Edge Computing — Beyond Caching
7.1 Running Code at the Edge
The CDN is no longer just a cache. Code runs there too:
- Cloudflare Workers (V8 isolate)
- Fastly Compute@Edge (WebAssembly)
- AWS Lambda@Edge (Node.js, Python)
- Vercel Edge Functions (V8)
- Deno Deploy (V8)
7.2 Use Cases
1. A/B testing
export default {
async fetch(request) {
const variant = Math.random() < 0.5 ? 'a' : 'b'
return fetch(`https://origin.com/page-${variant}`)
}
}
2. Geo routing
const country = request.cf.country
const origin = country === 'KR' ? 'asia.api.com' : 'us.api.com'
return fetch(origin + new URL(request.url).pathname)
3. Auth/authorization
const token = request.headers.get('Authorization')
const user = await verifyJWT(token)
if (!user) return new Response('Unauthorized', { status: 401 })
return fetch(origin)
4. HTML transformation
const response = await fetch(origin)
return new HTMLRewriter()
.on('h1', { element(el) { el.setInnerContent('Modified!') } })
.transform(response)
5. API aggregation
const [user, posts] = await Promise.all([
fetch('https://api.com/user/123').then(r => r.json()),
fetch('https://api.com/posts?user=123').then(r => r.json())
])
return new Response(JSON.stringify({ user, posts }))
7.3 Edge Computing Limits
- CPU time limit: typically 10–50ms (Cloudflare Workers 50ms)
- Memory limit: 128MB (Cloudflare), 50MB (Fastly)
- Cold start: almost zero on V8 isolates (under 5ms)
- Database access: calling origin DB from the edge is slow → need an edge DB (Turso, D1, Neon)
7.4 Edge Data Stores
| Cloudflare | Fastly | Vercel | |
|---|---|---|---|
| KV | Workers KV | Object Store | KV |
| DB | D1 (SQLite) | - | Neon, Turso |
| Object | R2 | - | Blob |
| Cache | Cache API | Cache | - |
| Vector | Vectorize | - | - |
8. CDN Cache Best Practices
8.1 Static Assets
# CSS, JS (hashed filenames)
Cache-Control: public, max-age=31536000, immutable
# Images (rarely change)
Cache-Control: public, max-age=86400, stale-while-revalidate=604800
8.2 HTML
# Changes often, but cacheable
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
max-age=0: browser revalidates every times-maxage=300: CDN caches for 5 minutesstale-while-revalidate=86400: stale usable for 24 hours
8.3 API JSON
# Per-user data (private)
Cache-Control: private, max-age=60, must-revalidate
# Public API
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400
8.4 Per-User Content
# Never cached
Cache-Control: private, no-store
Or include Cookie in the cache key (risky — cardinality explosion).
8.5 Compression + Caching
HTTP/1.1 200 OK
Content-Encoding: gzip
Cache-Control: public, max-age=3600
Vary: Accept-Encoding
Vary: Accept-Encoding is required — gzip vs br vs none are different cache entries.
9. Monitoring and Debugging
9.1 Checking Response Headers
curl -I https://example.com/api/users/123
HTTP/2 200
cf-cache-status: HIT
age: 234
cache-control: public, max-age=3600
Key headers:
cf-cache-status: HIT, MISS, EXPIRED, BYPASS, REVALIDATEDage: seconds since cachedx-cache: AWS CloudFront header
9.2 Cache States
HIT ← served from cache
MISS ← went to origin
EXPIRED ← expired, refreshing
REVALIDATED ← validated via 304
BYPASS ← cache bypassed (no-cache, etc.)
DYNAMIC ← uncacheable
9.3 Tracking Hit Ratio
Most CDNs expose it in their dashboard:
- Cloudflare Analytics
- Fastly Insights
- CloudFront Reports
9.4 Debugging Flow
Cache not hitting? Check:
- Cache-Control header: any
no-cacheorprivate? - Set-Cookie: presence of cookies disables caching
- Vary: too many variants
- Method: POST is usually not cached
- Status code: only 200, 301, 302, 404, 410 are cached
- Query string: is the CDN set to ignore query strings?
10. Real-World — Caching a Blog Site
10.1 Scenario
- WordPress or Next.js blog
- 1 million page views per day
- Posts get updated frequently
- Comment system
10.2 Caching Strategy
HTML pages:
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400
Static assets:
Cache-Control: public, max-age=31536000, immutable
API JSON:
Cache-Control: public, max-age=300, stale-while-revalidate=86400
10.3 Invalidation
On post update:
- Tag invalidation:
post-id,category-slug,homepage - New URLs: hash changes for static assets
// Next.js + Cloudflare
async function updatePost(id, data) {
await db.update(...)
await cf.purgeByTags([`post-${id}`, 'homepage'])
}
10.4 Results
- Cache hit ratio: 95%+
- Origin requests: 50k/day (95% reduction)
- Response time: 50ms (CDN) vs 500ms (origin)
- Cost: 95% reduction
Quiz
1. What's the difference between no-cache and no-store?
Answer: no-cache: cacheable, but the client must revalidate with origin (If-None-Match) before use. If it gets a 304, the cache can be served. Surprisingly, the cache is still useful. no-store: cache is absolutely forbidden. Neither the request nor the response is stored anywhere (memory, disk). Use this for sensitive data. A common mistake: using no-cache when you actually want "don't cache" — in reality the item is cached and simply revalidated.
2. Why is Stale-While-Revalidate a game changer?
Answer: It gives users an instant response every time. Even after the cache expires, stale data is returned immediately while the refresh happens in the background. Users don't wait, and the next request sees fresh data. Result: a perceived cache hit ratio of 100% while origin traffic stays low. Next.js ISR uses this pattern. SWR largely resolves the classic trade-off between freshness and speed.
3. Name three ways to prevent cache stampede.
Answer: (1) Mutex / distributed lock — for an expired key, only one request goes to origin while the others wait (or read stale), (2) Probabilistic Early Expiration — some requests refresh ahead of TTL, (3) Stale-While-Revalidate — serve the expired entry and refresh in the background. The hotter the key, the more important these protections are. Reddit and Facebook use similar patterns.
4. What happens when CDN cache hit ratio improves by 1%?
Answer: Origin traffic drops by 25% or more. 95% → 96% means origin requests go from 5% to 4% = 20% reduction. 96% → 97% means 4% → 3% = 25% reduction. Every extra 1% is worth more as hit ratio climbs. This affects (1) server cost, (2) bandwidth cost, (3) latency, and (4) reliability. Even a 0.5% improvement carries real business value.
5. What does "edge computing goes beyond caching" mean?
Answer: CDNs no longer just cache static files. They run code on nodes close to the user. Use cases: A/B testing, geo routing, auth, HTML transformation, API aggregation. Result: even dynamic content responds under 50ms. Cloudflare Workers runs on V8 isolates with cold starts under 5ms. Fastly Compute@Edge runs Wasm. The origin server's role shrinks while the edge does more of the work.
References
- MDN HTTP Caching
- RFC 7234 — HTTP Caching
- RFC 5861 — Stale Content — SWR definition
- Cloudflare Cache Docs
- Fastly Caching Best Practices
- HTTP Caching Best Practices — Google
- Cache Stampede
- SWR React Library — Vercel
- Cloudflare Workers
- Fastly Compute@Edge
- Vercel Edge Functions
- HTTP Caching Decision Tree — Harry Roberts