Frontend Caching toàn tập — Browser, CDN, Proxy, Service Worker
Đào sâu các tầng cache giữa user và origin: cache headers, browser memory/disk/bfcache, CDN edge, reverse proxy, Service Worker, và mọi pitfall thường gặp.
Performance frontend được quyết định bởi 1 câu hỏi đơn giản: request nào không phải gửi? Bytes không truyền là bytes nhanh nhất. Round-trip không xảy ra là round-trip không tốn TLS handshake, không tốn TCP slow start, không tốn bandwidth user.
Cache là đòn bẩy lớn nhất trong frontend perf — lớn hơn cả bundle size optimization, lớn hơn cả image format. Nhưng cache cũng là chỗ dễ sai nhất: cache sai thì user thấy version cũ cả tuần, cache không đủ thì server gánh load không cần thiết, cache nhầm response private thì data của user A leak sang user B.
Bài này đi từ tầng thấp nhất (browser memory cache) lên tầng cao nhất (CDN edge), giải thích mọi cache header HTTP, các strategy thường dùng, và những pitfall tôi từng dẫm phải trong production.
1. Cache hierarchy — request đi qua những tầng nào
Mỗi request từ browser đi qua một chuỗi các cache tier. Hit ở tầng càng sớm thì càng rẻ:
┌────────────────────────────────────────────────────────────────────┐
│ BROWSER (client) │
│ ┌──────────────┐ ┌─────────────┐ ┌───────────────────────┐ │
│ │ Memory cache │ │ Disk cache │ │ Service Worker cache │ │
│ │ (~RAM) │ ► │ (HTTP) │ ► │ (programmable) │ │
│ │ tab-life │ │ persistent │ │ origin-life │ │
│ └──────────────┘ └─────────────┘ └───────────────────────┘ │
└────────────────────────────────┬───────────────────────────────────┘
│ miss → network
▼
┌────────────────────────────────────────────────────────────────────┐
│ SHARED CACHES │
│ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Corporate / │ ► │ CDN edge (POP gần user) │ │
│ │ ISP proxy │ │ Cloudflare / Fastly / │ │
│ │ (legacy) │ │ CloudFront / Akamai │ │
│ └──────────────┘ └────────────┬─────────────┘ │
│ │ miss → CDN tiered → origin │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ Reverse proxy / shield │ │
│ │ (Varnish, Nginx) │ │
│ └────────────┬───────────┘ │
└────────────────────────────────────┼───────────────────────────────┘
▼
┌──────────────────┐
│ ORIGIN │
│ (Node, Rails…) │
└──────────────────┘
Có 2 nhóm cache với property rất khác nhau:
- Private cache (browser, SW): chỉ user đó dùng → có thể chứa data cá nhân hóa.
- Shared cache (CDN, proxy): nhiều user dùng chung → tuyệt đối không được chứa data cá nhân (auth response, profile…).
Phần lớn bug caching đến từ việc nhầm 2 nhóm này. Cache-Control: public
trên một response chứa cookie session là cách kinh điển để leak data
giữa user.
2. Vòng đời một request — từ cold tới 304
Trước khi mổ xẻ từng header, cần hình dung một response sống và chết như thế nào trong cache. Ba trạng thái lặp đi lặp lại: lần đầu (cold), trong khi còn fresh, và sau khi stale. Hiểu rõ 3 sequence này thì mọi directive ở §3 trở thành hệ quả tự nhiên thay vì danh sách phải học thuộc.
2.1. Lần đầu — cold cache
Cache rỗng → request đi tới origin, response về kèm directive freshness
và validator. Browser ghi cả body lẫn metadata (thời điểm nhận, max-age,
ETag):
Browser Origin
│── GET /api/data ────────────────────►│
│ │
│◄── 200 OK ────────────────────────────│
│ Cache-Control: max-age=3600 │
│ ETag: "abc123" │
│ Body: {...} │
│ │
│ store → cached_at = now │
│ max_age = 3600 │
│ etag = "abc123" │
2.2. Trong cửa sổ fresh — không chạm mạng
Đây là pha có giá trị nhất: request không rời máy. Browser tự tính
tuổi response (xem thuật toán đầy đủ ở §3.8) và so với max-age:
Browser
│ now − cached_at = 30 min
│ max-age = 60 min → CHƯA hết hạn
│
│ serve thẳng từ disk, KHÔNG gửi request
│ DevTools: Size = "(disk cache)" / "(memory cache)"
Không round-trip, không TLS, không origin load — bytes nhanh nhất là bytes không truyền.
2.3. Sau khi stale — conditional request & 304
Hết fresh, browser không vứt entry đi. Nó gửi conditional request kèm validator để hỏi origin “còn đúng không?”. Origin trả 304 (rỗng body) nếu chưa đổi, hoặc 200 (body mới) nếu đã đổi:
Browser Origin
│── GET /api/data ────────────────────►│
│ If-None-Match: "abc123" │
│ │
│ ─ Nhánh A: data CHƯA đổi ───────────► │
│◄── 304 Not Modified ──────────────────│ ← không body, ~vài trăm byte
│ (dùng lại body cũ trong cache) │
│ │
│ ─ Nhánh B: data ĐÃ đổi ────────────► │
│◄── 200 OK ────────────────────────────│ ← body mới
│ ETag: "xyz789" │
│ ghi đè entry cũ │
304 tiết kiệm toàn bộ payload — chỉ trả header. Với JSON list vài trăm KB, đây là chênh lệch giữa 5ms và 500ms trên mạng yếu.
2.4. no-cache đảo lịch trình
Khác biệt mấu chốt giữa max-age và no-cache nằm ở khi nào bước
revalidate (2.3) xảy ra:
max-age=3600
0–3600s : KHÔNG request, serve thẳng cache (pha 2.2)
> 3600s : conditional request (pha 2.3)
no-cache
MỌI lúc : conditional request (luôn pha 2.3)
→ 304 thì dùng cache, 200 thì thay mới
Nói cách khác no-cache bỏ qua hẳn pha fresh: luôn hỏi origin, nhưng
vẫn tận dụng 304 để khỏi tải lại body. Đây chính là lý do no-cache
không phải “không cache” — chi tiết ở §3.2.
3. HTTP cache headers — chi tiết toàn bộ
3.1. Cache-Control — header quan trọng nhất
Cache-Control là source of truth hiện đại. Mọi directive đều có ý
nghĩa riêng và chỉ áp dụng cho đúng tầng cache nào đó:
| Directive | Áp dụng | Ý nghĩa |
|---|---|---|
public | Cả 2 | Cho phép shared cache (CDN, proxy) lưu |
private | Browser | Chỉ browser được lưu, CDN phải bỏ qua |
no-cache | Cả 2 | Lưu được, nhưng phải revalidate trước khi serve |
no-store | Cả 2 | Không được lưu ở bất kỳ tầng nào |
max-age=N | Cả 2 | Coi response là fresh trong N giây |
s-maxage=N | Shared | Override max-age cho shared cache |
must-revalidate | Cả 2 | Hết hạn rồi không được serve stale, phải revalidate |
immutable | Browser | Trong max-age không cần revalidate ngay cả khi user reload |
stale-while-revalidate=N | Cả 2 | Sau khi expire, vẫn serve stale trong N giây + refresh background |
stale-if-error=N | Cả 2 | Nếu origin error, serve stale trong N giây |
no-transform | Cả 2 | Cấm proxy nén lại / chuyển format |
Một số combo phổ biến đáng nhớ:
# 1. Static asset có fingerprint trong URL (app.a3f9.js)
# → cache 1 năm, bỏ qua revalidate. Đổi nội dung = đổi URL.
Cache-Control: public, max-age=31536000, immutable
# 2. HTML shell (index.html) — thay đổi mỗi deploy
# → không cache, luôn revalidate, nhưng cho phép stale ngắn nếu CDN miss
Cache-Control: no-cache, must-revalidate
# 3. API response GET không nhạy cảm (ví dụ: /api/products)
# → CDN edge cache 60s, browser 30s, fallback stale 10 phút khi origin lỗi
Cache-Control: public, max-age=30, s-maxage=60, stale-while-revalidate=600, stale-if-error=600
# 4. API response auth (cá nhân hóa)
# → chỉ browser cache, ngắn, không bao giờ shared
Cache-Control: private, max-age=0, must-revalidate
3.2. no-cache ≠ “không cache”
Đây là confusion phổ biến nhất. no-cache vẫn cho lưu — chỉ là phải
revalidate (gửi conditional request kiểm tra ETag/Last-Modified) trước
khi serve. Nếu muốn cấm tuyệt đối, phải dùng no-store.
no-cache → store OK, phải hỏi origin "còn fresh không?" trước serve
no-store → không bao giờ ghi vào cache
max-age=0 → store OK, expire ngay → tương đương no-cache (cũ hơn)
max-age=0
+ must-revalidate → giống no-cache nhưng strict hơn về stale
3.3. Expires — legacy nhưng chưa chết
Expires: Sat, 30 Apr 2027 12:00:00 GMT là HTTP/1.0. Khi cùng tồn tại
với Cache-Control: max-age, max-age thắng. Lý do giữ Expires:
một số corporate proxy cũ chỉ hiểu HTTP/1.0. Modern stack có thể bỏ qua.
3.4. Validators — ETag và Last-Modified
Khi response expire, browser không vứt cache đi ngay. Nó gửi conditional request kiểm tra origin:
# Lần đầu: response chứa validator
HTTP/1.1 200 OK
ETag: "v3-c4ca4238a0b923820"
Last-Modified: Tue, 28 Apr 2026 10:00:00 GMT
Cache-Control: max-age=60
# Sau 61s, browser revalidate:
GET /api/products HTTP/1.1
If-None-Match: "v3-c4ca4238a0b923820"
If-Modified-Since: Tue, 28 Apr 2026 10:00:00 GMT
# Origin nếu vẫn fresh trả về 304 KHÔNG body
HTTP/1.1 304 Not Modified
ETag: "v3-c4ca4238a0b923820"
Cache-Control: max-age=60
ETag là string opaque — origin tự định nghĩa. Có 2 loại:
- Strong ETag (
"abc123"): byte-by-byte identical. - Weak ETag (
W/"abc123"): semantic equivalent (ví dụ JSON cùng data nhưng key order khác).
Với JSON API, weak ETag là đủ và rẻ hơn. Tính ETag từ hash content:
// src/server/etag.ts
// Compute weak ETag từ response body. Dùng SHA-1 truncate vì:
// (1) collision-resistant đủ cho cache key (không phải security primitive),
// (2) ngắn → header không phình, (3) nhanh hơn SHA-256 cho payload nhỏ.
import { createHash } from 'node:crypto';
export function weakETag(body: string | Buffer): string {
const hash = createHash('sha1').update(body).digest('base64url').slice(0, 22);
return `W/"${hash}"`;
}
// Express handler ví dụ
export function withETag<T>(handler: () => Promise<T>) {
return async (req: Request, res: Response) => {
const data = await handler();
const body = JSON.stringify(data);
const etag = weakETag(body);
// Nếu client gửi If-None-Match khớp → 304, tiết kiệm payload.
if (req.headers['if-none-match'] === etag) {
res.status(304).end();
return;
}
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', 'public, max-age=30, s-maxage=60');
res.json(data);
};
}
Rule of thumb: ETag chính xác hơn Last-Modified vì timestamp chỉ
có resolution 1 giây (HTTP date format). Nếu file đổi 2 lần trong cùng
1 giây, Last-Modified không phát hiện được. Tuy vậy, Last-Modified
rẻ hơn (không phải hash content) — phù hợp với static file lớn.
3.5. Vary — chìa khóa để cache đúng theo client
Cùng URL nhưng response khác nhau theo header request → phải dùng
Vary để CDN biết tách thành các cache entry riêng:
# Server trả gzip cho browser hỗ trợ, plain cho cũ
Content-Encoding: gzip
Vary: Accept-Encoding
# Server trả ngôn ngữ theo Accept-Language
Vary: Accept-Language
# Trả mobile vs desktop layout
Vary: User-Agent ← NGUY HIỂM, đọc tiếp
Cảnh báo: Vary: User-Agent sẽ tạo cache entry cho mỗi UA string
unique — và UA string có hàng triệu biến thể nhỏ → cache hit ratio
sụp đổ. Thay vì vậy, normalize UA ở edge thành “mobile” / “desktop” rồi
Vary: X-Device-Type.
Tương tự Vary: Cookie rất nguy hiểm — mỗi cookie change tạo entry mới.
3.6. Các header phụ trợ
Age(do CDN set): response đã ở cache bao lâu, đơn vị giây. Browser tính freshness =max-age - Age.Date: thời điểm origin tạo response. Dùng để tínhAgekhi proxy không set.Surrogate-Control: dành riêng cho CDN, browser bỏ qua. Cho phép tách cấu hình “browser cache 30s, CDN 1 giờ” mà không cầns-maxage.CDN-Cache-Control: chuẩn hóa mới (RFC 9213), một số CDN ưu tiên trênSurrogate-Control.
3.7. Ba sắc thái directive hay bị hiểu sai
Bảng ở §3.1 cho biết directive làm gì. Ba điểm dưới đây là chỗ trực giác hay sai — đáng nhớ riêng.
max-age đếm từ Date, không phải từ lúc browser nhận. Đồng hồ
freshness bắt đầu chạy ở thời điểm origin tạo response (Date header),
nên thời gian response lê la qua CDN bị trừ thẳng vào tuổi thọ:
Origin tạo response CDN giữ Browser nhận
10:00:00 (5s) 10:00:05
│ │
max-age=3600 → fresh_until = Date + max-age = 11:00:00
→ Browser KHÔNG có 3600s kể từ 10:00:05; nó chỉ còn 3595s.
Header Age (§3.6) là cách CDN nói cho browser biết “tôi đã giữ nó N
giây rồi” để browser trừ đúng. Một response tới tay với Age: 3600 và
max-age: 3600 là đã stale ngay khi nhận.
immutable tắt cả revalidate khi user bấm reload. Bình thường, F5 /
Cmd-R khiến browser gửi conditional request kể cả khi entry còn fresh —
tốn 1 RTT chỉ để nhận 304 cho file không bao giờ đổi. immutable nói
“đừng hỏi, cứ dùng”:
max-age=31536000 → reload vẫn revalidate → 304 (phí 1 RTT)
max-age=31536000, immutable → reload serve thẳng cache, 0 request
Chỉ an toàn với asset có fingerprint trong URL (app.a3f9.js): đổi nội
dung = đổi URL, nên “không bao giờ đổi” là lời hứa giữ được.
public là cách opt-in cho response có Authorization. Theo RFC,
nếu request mang header Authorization, shared cache mặc định không
được lưu response — một safe default chống leak. Khi response thật sự
public (vd signed URL trỏ tới asset công khai), public mở khóa lại cho
CDN cache. Đừng dùng nó cho data cá nhân hóa — đó là §10.3.
3.8. Freshness & Age — thuật toán browser thực sự chạy
Phần lớn người dùng cache nghĩ đơn giản “có max-age thì cache, hết hạn
thì bỏ”. Thực tế RFC 9111 định nghĩa một thuật toán cụ thể. Một response
được coi là fresh khi:
is_fresh ⇔ freshness_lifetime > current_age
Bước 1 — tính freshness_lifetime theo thứ tự ưu tiên (cái nào có
trước thì thắng):
1. s-maxage (chỉ shared cache dùng)
2. max-age
3. Expires − Date (HTTP/1.0 fallback)
4. Heuristic (khi KHÔNG có cả 3 cái trên)
Heuristic freshness là phần ít ai biết: nếu response cacheable theo mặc định (200, 203, 204, 206, 300, 301, 404, 410…) nhưng không có directive expiration nào, cache được phép tự đoán tuổi thọ. Công thức phổ biến nhất (Chrome, Firefox, nginx, Varnish):
heuristic_lifetime = (Date − Last-Modified) × 10%
Tức một file sửa lần cuối 100 ngày trước, không có Cache-Control, sẽ
được cache “đoán” fresh trong ~10 ngày. Đây là nguồn của bug kinh điển:
“tôi đâu có set cache mà sao browser vẫn giữ file cũ?” — chính heuristic
đang chạy. Muốn tắt: set Cache-Control: no-cache rõ ràng.
Bước 2 — tính current_age. Đây không phải now − Date đơn giản,
vì response có thể đã nằm sẵn ở một CDN tier nào đó trước khi tới bạn.
RFC 9111 §4.2.3 tính cả độ trễ mạng và thời gian “cư trú” ở từng cache:
# Các mốc thời gian: request_time / response_time = lúc cache GỬI request
# và NHẬN response. age_value = giá trị header `Age` (do upstream set).
apparent_age = max(0, response_time − Date)
corrected_age_value = age_value + (response_time − request_time)
corrected_initial_age = max(apparent_age, corrected_age_value)
resident_time = now − response_time
current_age = corrected_initial_age + resident_time
Điểm mấu chốt: header Age cộng dồn qua từng tầng. Browser nhận
response với Age: 40 (đã nằm ở edge 40s) và max-age=60 → nó chỉ còn
coi là fresh thêm 20s nữa, không phải 60s. Đây là lý do Age quan trọng
khi debug “tại sao response này stale nhanh thế”.
Ví dụ số cụ thể:
Origin trả: Cache-Control: max-age=600, Date: 12:00:00
CDN giữ 120s rồi trả cho browser kèm Age: 120 (lúc 12:02:00)
Browser nhận lúc 12:02:00, giữ thêm 90s → kiểm tra lúc 12:03:30:
current_age = 120 (từ CDN) + 90 (ở browser) = 210s
freshness_lifetime = 600s
210 < 600 → VẪN FRESH, serve thẳng từ disk, không chạm mạng
3.9. Revalidation — chuyện gì xảy ra khi hết fresh
Khi current_age ≥ freshness_lifetime, cache không vứt entry đi. Nó
chuyển sang revalidate: gửi conditional request và xử lý theo kết quả.
stale entry có validator?
├─ có ETag → gửi If-None-Match: "<etag>"
└─ có Last-Mod→ gửi If-Modified-Since: <date>
│
▼
origin so sánh
├─ chưa đổi → 304 Not Modified (KHÔNG body)
│ → cache cập nhật lại header (max-age, Date…) + reset tuổi
│ → serve body CŨ từ cache (tiết kiệm toàn bộ payload)
└─ đã đổi → 200 OK (có body mới) → ghi đè entry
Vài chi tiết hay bị bỏ sót:
If-None-Matchưu tiên hơnIf-Modified-Since. Nếu client gửi cả hai, origin chuẩn RFC sẽ chỉ xét ETag, bỏ qua timestamp.- 304 phải lặp lại các header định freshness (
Cache-Control,Expires) để cache reset đồng hồ. Quên set ở nhánh 304 → entry hết fresh ngay lập tức → revalidate liên tục mỗi request. must-revalidatevs mặc định: mặc định, một số cache được phép serve stale khi không revalidate được (mất mạng).must-revalidatecấm điều đó — stale là phải chặn, trả lỗi 504 còn hơn serve sai. Dùng cho dữ liệu mà “cũ” là nguy hiểm (số dư tài khoản, tồn kho).stale-while-revalidateđảo ngược trải nghiệm: thay vì chờ revalidate xong mới serve, nó serve stale ngay rồi revalidate nền. User không bao giờ thấy độ trễ revalidate (xem chi tiết multi-tier ở §5).
4. Browser cache layers — không chỉ một
Browser không chỉ có 1 cache. Có ít nhất 4 layer riêng biệt:
| Layer | Lifetime | Speed | Trigger |
|---|---|---|---|
| Memory cache | Tab session | ~1ms | Resource đã load lần này |
| Disk cache (HTTP) | Persistent | ~10-50ms | Cache-Control / Expires |
| Service Worker | Programmable | ~10ms | Code điều khiển |
| BFCache | Back/forward nav | ~instant | Page snapshot trước khi rời |
4.1. Memory vs Disk cache
Mở DevTools → Network, bạn sẽ thấy “from memory cache” hoặc “from disk cache”. Sự khác nhau:
- Memory cache: live trong tab. F5 thì còn, đóng tab thì mất. Image, JS, CSS đã eval xong trong tab hiện tại lưu ở đây.
- Disk cache: persistent, dùng chung giữa các tab cùng process. Áp dụng quy tắc HTTP cache thông thường.
Browser tự quyết định — bạn không control trực tiếp được. Tuy nhiên có thể nudge:
Cache-Control: immutable: ngay cả khi user reload, browser sẽ không revalidate (memory hit hoặc disk hit thẳng).- Tránh
Pragma: no-cache(HTTP/1.0 legacy).
4.2. Back/forward cache (bfcache) — viên đạn bạc bị quên lãng
Khi user click back, browser có thể restore page từ snapshot trong RAM thay vì re-render. Kết quả: navigation gần như instant, không fetch lại gì cả. Đây không phải HTTP cache — đây là cache toàn bộ DOM + JS heap.
Điều kiện để bfcache work:
- Không có
Cache-Control: no-storeở document response. - Không dùng
unloadevent (ngừng đi, dùngpagehidethay). - Không có outstanding IndexedDB transaction.
- Không có WebSocket / WebRTC còn open.
Test: thêm event listener và quan sát:
// src/lib/bfcache-debug.ts
// Quan sát bfcache events. Đặt ở entry script để theo dõi rate hit/miss
// trong production (gửi về analytics).
window.addEventListener('pageshow', (e) => {
if (e.persisted) {
console.log('[bfcache] HIT — page restored from snapshot');
}
});
window.addEventListener('pagehide', (e) => {
// e.persisted = browser định lưu page vào bfcache.
// Nếu false: kiểm tra notRestoredReasons API (Chrome 105+) để debug.
if (!e.persisted) {
const reasons = (performance.getEntriesByType('navigation')[0] as any)
?.notRestoredReasons;
if (reasons) console.warn('[bfcache] BLOCKED:', reasons);
}
});
Một bug rất phổ biến: dev set Cache-Control: no-store cho HTML “để
chắc chắn always fresh” → vô tình tắt bfcache → mọi back/forward đều
phải re-render lại từ đầu. Dùng no-cache thay no-store cho HTML.
4.3. Cache partitioning — tại sao asset CDN dùng chung không còn được reuse
Trước 2020, HTTP cache của browser dùng key đơn: chỉ là URL. Hệ quả:
nếu site-a.com và site-b.com cùng nhúng https://cdn.jsdelivr.net/jquery.js,
file đó tải 1 lần và dùng chung cho mọi site. Nghe thì hiệu quả,
nhưng nó tạo 2 lỗ hổng:
- Tracking xuyên site: site B đo thời gian load file → biết bạn đã ghé site A (vì file đã nóng trong cache).
- Cross-site search / leak: dò sự tồn tại của resource riêng tư.
Từ Chrome 85 (2020), Firefox và Safari, HTTP cache bị partition (double/triple-keyed):
Cache key cũ: ( resource URL )
Cache key mới: ( top-level site, [frame site], resource URL )
Hệ quả thực tế mà nhiều dev không biết: public CDN cho thư viện chung
gần như mất sạch lợi ích cross-site. jquery.js từ một CDN dùng chung
giờ được tải lại riêng cho từng site bạn ghé. Bài học: self-host asset
quan trọng (hoặc dùng CDN riêng của bạn) thay vì trông chờ “user chắc
đã cache sẵn từ site khác” — điều đó không còn đúng. Đây cũng là lý do
“shared CDN giúp web nhanh hơn” trở thành huyền thoại lỗi thời.
Cache API (Service Worker) và các storage khác cũng bị partition theo
cùng nguyên tắc, nên iframe bên thứ ba không thể đọc cache của top-level.
4.4. Eviction — cache đầy thì ai bị xoá
Cache không vô hạn. Mỗi tier có ngân sách và chính sách dọn riêng, và bạn không control trực tiếp — chỉ nudge được:
| Tier | Dung lượng điển hình | Chính sách evict |
|---|---|---|
| Memory cache | Vài chục MB / renderer | LRU, xoá sạch khi đóng tab |
| Disk cache | ~vài % dung lượng đĩa, có cap | LRU theo last-access, ưu tiên giữ nóng |
| Cache API/IDB | Theo quota origin (Storage) | Bị evict khi đĩa cạn, trừ khi persist() |
Vài điều đáng nhớ:
-
Disk cache là LRU theo lần truy cập gần nhất, không phải theo
max-age. Một fileimmutable, max-age=1yearvẫn có thể bị xoá sau vài ngày nếu user không ghé lại và cache đầy.immutablechỉ hứa “không revalidate”, không hứa “không bị evict”. -
Storage eviction: data của Cache API/IndexedDB nằm trong nhóm “best-effort” và có thể bị xoá khi đĩa cạn. Muốn giữ chắc (offline app), xin persistent storage:
// Xin storage bền — browser sẽ không tự evict khi đĩa cạn. // Trả false nếu user/heuristic từ chối (vd site chưa đủ "engaged"). const persisted = await navigator.storage.persist(); const { quota, usage } = await navigator.storage.estimate(); console.log(`Dùng ${usage} / ${quota} bytes, persisted=${persisted}`); -
Clear-Site-Dataheader cho phép server ra lệnh xoá cache/storage của chính origin đó (hữu ích khi logout hoặc rotate phiên bản lớn):Clear-Site-Data: "cache", "storage", "cookies"
5. CDN edge cache — gần user nhất
CDN (Cloudflare, Fastly, CloudFront, Akamai…) đặt server tại hàng trăm Point of Presence (POP) khắp thế giới. User Sài Gòn fetch sẽ chạm edge ở Singapore (~30ms RTT), không phải origin ở Iowa (~250ms RTT).
5.1. Cache key
CDN xác định “2 request có cùng cached response không” bằng cache key.
Mặc định: (method, host, path, query string). Có thể custom thêm:
- Bỏ qua marketing query (
utm_*,fbclid) → tăng hit ratio. - Include header chọn lọc (
Accept-Encoding,X-Device-Type) thay vìVary: Accept-Encodingtoàn bộ UA. - Strip cookies trừ session-critical ones.
Ví dụ Cloudflare Workers normalize URL trước khi check cache:
// src/edge/normalize-cache-key.ts
// Worker chạy ở edge, normalize URL trước khi check cache.
// Loại bỏ tracking params giúp 10 user click 10 link UTM khác nhau
// vẫn share cùng 1 cache entry → hit ratio từ ~40% lên ~85%.
const TRACKING_PARAMS = [
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'fbclid', 'gclid', 'mc_cid', 'mc_eid', '_ga', 'ref',
];
export default {
async fetch(request: Request, env: unknown, ctx: ExecutionContext) {
const url = new URL(request.url);
for (const param of TRACKING_PARAMS) url.searchParams.delete(param);
const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
let response = await cache.match(cacheKey);
if (!response) {
response = await fetch(request); // origin
// ctx.waitUntil để cache.put không block response trả về user.
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;
},
};
5.2. Stale-while-revalidate ở edge
stale-while-revalidate (SWR) cho phép edge trả ngay response stale
cho user trong khi async fetch về origin. User không bao giờ chờ origin
ngoại trừ lần đầu cold.
fresh window stale-while-revalidate window
│◄─── 60s ───────►│◄────── 600s ──────►│
user req at t=0 │ HIT (fresh) │ │
user req at t=50 │ HIT (fresh) │ │
user req at t=80 │ │ HIT (stale) │ ← serve stale
│ │ + bg revalidate │ ← refresh edge
user req at t=85 │ │ HIT (now fresh) │
user req at t=700 │ │ │ MISS → origin
Cấu hình điển hình:
Cache-Control: public, max-age=60, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400
Đọc: “browser fresh 60s, edge fresh 60s, sau đó edge serve stale tới 10 phút trong khi background refresh, và nếu origin chết edge serve stale tới 1 ngày.” Đây là một trong những combo cấu hình mạnh nhất cho API content-heavy.
5.3. Purging — hủy cache khi data đổi
3 chiến lược chính:
| Cơ chế | Latency | Granularity | Cost |
|---|---|---|---|
| URL purge | ~vài giây | Per-URL | Rẻ |
| Tag-based purge | ~vài giây | Per-resource | Cần plan tốt |
| Soft purge | 0 | Toàn bộ | Không downtime |
Tag-based purge (Fastly / Cloudflare Enterprise) là kỹ thuật mạnh
nhất: mỗi response gắn Surrogate-Key: product-123 category-shoes,
khi product 123 đổi giá thì purge bằng 1 API call:
// src/server/cache-tags.ts
// Khi serve response, gắn surrogate keys liên quan. Khi data đổi,
// chỉ cần purge by key — không cần track URL nào đã cache.
export function tagResponse(res: Response, tags: string[]) {
// Surrogate-Key dùng space delimiter, không có format chuẩn nhưng
// Fastly và Cloudflare Enterprise đều hiểu vậy.
res.setHeader('Surrogate-Key', tags.join(' '));
}
// Example: product detail page
tagResponse(res, [
`product-${product.id}`,
`category-${product.categoryId}`,
`vendor-${product.vendorId}`,
]);
// Khi update product → purge tag duy nhất, không quan tâm URL nào dính.
async function purgeProductCache(productId: string) {
await fetch(`https://api.fastly.com/service/${SERVICE_ID}/purge/product-${productId}`, {
method: 'POST',
headers: { 'Fastly-Key': API_KEY },
});
}
Soft purge: thay vì xoá thẳng, mark response là expired ngay → SWR serve stale tới user trong khi origin được fetch lại. 0 downtime, 0 origin spike — đây là default nên dùng cho production.
5.4. Cache stampede — khi một key hết hạn làm sập origin
Tình huống: một URL rất hot (trang chủ, API trending) đang được cache. Đúng giây nó expire, 10.000 request đang bay tới cùng lúc đều miss, và tất cả cùng lao về origin để regenerate. Origin vốn chỉ quen phục vụ ~10 req/s đột nhiên nhận 10.000 — CPU spike, DB nghẽn, có khi sập. Đây gọi là cache stampede (hay thundering herd / dogpile). Càng cache tốt thì stampede càng đau, vì origin đã quen tải thấp.
Ba lớp phòng thủ, từ rẻ tới mạnh:
1. Request coalescing (collapsed forwarding). Cache “khoá” key đang miss: chỉ một request được forward về origin, số còn lại chờ kết quả đó rồi cùng nhận. 10.000 miss → 1 origin fetch.
miss đồng thời
req1 ─┐
req2 ─┼─► [edge: key đang fetch?] ─ chưa ─► 1 fetch origin
req3 ─┘ │ rồi │
... └────── chờ ◄──────────────────┘ (cùng nhận 1 response)
Bật sẵn ở: Varnish (mặc định coalesce), Nginx proxy_cache_lock on;,
Cloudflare (Cache Lock / concurrent streaming), Fastly (request
collapsing mặc định).
2. Stale-while-revalidate. Như đã nói — khi stale, serve bản cũ ngay cho mọi người, chỉ một revalidation chạy nền. Không ai chờ origin, origin chỉ nhận 1 hit. SWR + coalescing là combo gần như miễn nhiễm stampede.
3. Probabilistic early expiration (XFetch). Thay vì để cả triệu key hết hạn cùng mốc, mỗi request tự xác suất làm mới sớm trước hạn — xác suất tăng dần khi gần expiry. Giúp “trải” việc regenerate ra thay vì dồn cục. Hữu ích ở tầng application cache (Redis/memcached):
// src/server/xfetch.ts
// Probabilistic early recomputation (XFetch, Vattani et al.).
// delta = thời gian tính giá trị; beta > 1 = càng "hung hăng" làm mới sớm.
// Khi (now + delta*beta*ln(rand)) vượt expiry → tự recompute trước hạn,
// nên rất hiếm khi tất cả key cùng hết hạn một lúc.
function shouldRecompute(expiry: number, delta: number, beta = 1): boolean {
const xfetch = delta * beta * Math.log(Math.random()); // luôn ≤ 0
return Date.now() - xfetch >= expiry;
}
5.5. Tiered cache & origin shield — đừng để mọi POP đập vào origin
Một CDN có hàng trăm POP. Nếu mỗi POP miss đều đi thẳng origin, thì origin vẫn nhận tải tỉ lệ với số POP, không phải số origin — và coalescing ở từng POP riêng lẻ không gộp được cross-POP.
Tiered caching dựng một lớp cache trung gian: edge POP miss → hỏi một parent/regional tier → tier đó miss mới đi origin.
user ─► edge POP (lá)
│ miss
▼
regional tier (cha) ◄── nhiều edge POP chia sẻ chung
│ miss
▼
origin shield (1 POP cố định gần origin)
│ miss
▼
ORIGIN ← chỉ thấy traffic đã được gộp tối đa
Origin shield là một POP duy nhất được chỉ định làm cửa ngõ về origin. Lợi ích kép: (1) hit ratio tổng tăng (một POP fetch xong thì các POP khác hit qua tier), (2) request coalescing gộp ở cấp toàn cầu — origin có thể chỉ thấy 1 request cho một key dù cả thế giới đang miss. Cloudflare gọi là Tiered Cache / Argo, Fastly có Shielding, CloudFront có Origin Shield. Với origin yếu hoặc đắt (server-render nặng), đây là công tắc giảm tải lớn nhất bạn có thể bật.
6. Reverse proxy — CDN tự host
Trước thời CDN-as-a-service, các site lớn dùng Varnish đặt trước origin. Vẫn dùng nhiều khi:
- Privacy / on-prem (không thể đẩy data ra third-party CDN).
- Dynamic content cần custom logic mà CDN edge worker không đủ.
- Cost tối ưu hơn ở scale rất lớn.
Một Varnish VCL đơn giản:
# /etc/varnish/default.vcl
sub vcl_recv {
# Strip cookies cho asset tĩnh — cookie không ảnh hưởng response,
# giữ cookie sẽ làm Varnish bypass cache (default behavior).
if (req.url ~ "\.(jpg|jpeg|png|gif|webp|css|js|woff2)$") {
unset req.http.Cookie;
}
}
sub vcl_backend_response {
# Edge giữ asset 1 ngày kể cả khi origin set ngắn hơn.
# (s-maxage thắng max-age cho shared cache.)
if (bereq.url ~ "\.(jpg|jpeg|png|gif|webp)$") {
set beresp.ttl = 1d;
}
# Grace mode: serve stale tới 1 giờ khi origin down.
set beresp.grace = 1h;
}
Nginx cũng làm reverse-proxy cache được, nhưng kém linh hoạt hơn Varnish ở mảng key/grace logic.
7. Service Worker — cache lập trình được
Service Worker là HTTP cache mà bạn code được. Browser tự nó cache theo header; SW cho phép bạn override hoàn toàn — bao gồm offline, strategy phức tạp, và cache asset không có cache header tốt.
// public/sw.ts (compile xuống public/sw.js)
// SW sống ở scope của origin, persistence không bị ảnh hưởng bởi
// Cache-Control của resource. Đây là "cache last resort" cho offline.
declare const self: ServiceWorkerGlobalScope;
const VERSION = 'v3';
const STATIC_CACHE = `static-${VERSION}`;
const RUNTIME_CACHE = `runtime-${VERSION}`;
const PRECACHE_URLS = [
'/',
'/offline.html',
'/_astro/app.css',
'/_astro/app.js',
];
self.addEventListener('install', (event) => {
// skipWaiting: SW mới active ngay, không chờ tab cũ đóng.
// Trade-off: page đang load có thể bị mismatch nếu asset mới khác cũ.
// Chỉ dùng skipWaiting khi versioning đảm bảo backward compat.
event.waitUntil(
caches.open(STATIC_CACHE).then((c) => c.addAll(PRECACHE_URLS))
);
});
self.addEventListener('activate', (event) => {
// Dọn cache cũ khi version đổi. Quan trọng — không có bước này thì
// disk fill dần từng deploy.
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => !k.endsWith(VERSION))
.map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
// Routing strategy: chọn theo loại resource
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Asset có fingerprint → cache-first (nội dung không bao giờ đổi).
if (url.pathname.startsWith('/_astro/')) {
event.respondWith(cacheFirst(event.request, STATIC_CACHE));
return;
}
// HTML → network-first với fallback offline.
if (event.request.mode === 'navigate') {
event.respondWith(networkFirst(event.request, RUNTIME_CACHE));
return;
}
// API → stale-while-revalidate.
if (url.pathname.startsWith('/api/')) {
event.respondWith(staleWhileRevalidate(event.request, RUNTIME_CACHE));
}
});
async function cacheFirst(req: Request, cacheName: string): Promise<Response> {
const cached = await caches.match(req);
if (cached) return cached;
const res = await fetch(req);
// Chỉ cache 200 OK + same-origin, tránh cache CORS error.
if (res.ok) {
const cache = await caches.open(cacheName);
cache.put(req, res.clone());
}
return res;
}
async function networkFirst(req: Request, cacheName: string): Promise<Response> {
try {
const res = await fetch(req);
const cache = await caches.open(cacheName);
cache.put(req, res.clone());
return res;
} catch {
const cached = await caches.match(req);
return cached ?? caches.match('/offline.html') as Promise<Response>;
}
}
async function staleWhileRevalidate(
req: Request,
cacheName: string
): Promise<Response> {
const cache = await caches.open(cacheName);
const cached = await cache.match(req);
// Background revalidate — không await, để cache hit trả ngay lập tức.
const networkPromise = fetch(req).then((res) => {
if (res.ok) cache.put(req, res.clone());
return res;
});
return cached ?? networkPromise;
}
7.1. Lifecycle — vì sao SW mới không chịu chạy ngay
Hiểu sai lifecycle là gốc của hầu hết bug SW. Một SW đi qua các state cố định, và mặc định rất bảo thủ để không làm gãy tab đang mở:
(mới) parsed → installing → installed/waiting → activating → activated → redundant
│ install ev. │ (chờ) │ activate ev.
▼ ▼ ▼
precache asset SW cũ vẫn điều dọn cache cũ,
khiển mọi tab claim clients
Mấu chốt: khi deploy sw.js mới, browser cài nó nhưng đặt vào trạng
thái waiting — SW cũ vẫn nắm quyền cho tới khi mọi tab dùng SW cũ
đóng hết. Vì vậy “tôi deploy rồi mà reload vẫn chạy SW cũ” là đúng
như thiết kế, không phải bug.
Hai công tắc thay đổi hành vi này:
skipWaiting()(tronginstall): SW mới nhảy thẳng qua waiting, active ngay. Đánh đổi: tab đang mở vốn được serve bởi SW cũ giờ đột ngột bị SW mới phục vụ → nếu asset mới không tương thích ngược, page đang chạy có thể vỡ. Chỉ dùng khi versioning đảm bảo backward-compat.clients.claim()(trongactivate): SW vừa active giành quyền điều khiển các tab đang mở ngay lập tức (bình thường phải chờ navigate kế tiếp). Thường đi cặp vớiskipWaiting().
Browser tự kiểm tra bản sw.js mới khi navigate (và định kỳ ~24h). Nó
so byte file script: chỉ cần khác 1 byte là coi như có bản mới. Nhưng
nếu chính sw.js bị HTTP-cache thì browser có thể lấy bản cũ khi check
→ kẹt. Vì vậy:
// updateViaCache: 'none' → browser KHÔNG dùng HTTP cache cho file sw.js
// khi check update (vẫn cache các resource khác bình thường).
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
7.2. Navigation Preload — bù độ trễ khởi động SW
Có một chi phí ẩn: khi SW đang “ngủ” (bị browser tắt để tiết kiệm RAM),
một navigation phải chờ SW boot lại rồi mới chạy fetch handler —
thêm hàng chục–trăm ms trước cả khi request mạng bắt đầu. Với chiến lược
network-first, đó là delay thuần.
Navigation Preload cho browser bắt đầu request mạng song song với lúc SW đang khởi động, rồi đưa response đó vào handler:
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
// Bật preload: browser fetch navigation song song khi SW boot.
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
})()
);
});
self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate') return;
event.respondWith(
(async () => {
// Dùng response preload nếu có (đã bay sẵn khi SW đang dậy).
const preloaded = await event.preloadResponse;
if (preloaded) return preloaded;
try {
return await fetch(event.request);
} catch {
return (await caches.match('/offline.html'))!;
}
})()
);
});
Pitfall hay gặp với SW:
-
SW stuck: deploy version mới nhưng user cũ vẫn dùng SW cũ vì browser chỉ check SW update mỗi 24h hoặc khi navigate. Set
updateViaCache: 'none'ở registration:navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }); -
HTML cache by SW không có chiến lược version → user kẹt UI cũ. Network-first cho HTML là default an toàn.
-
Cache không bao giờ giải phóng: thêm
caches.delete()cho old version trongactivate.
8. Application storage — không phải HTTP cache
Đây là storage do app code quản lý, hoàn toàn không liên quan HTTP cache. So sánh:
| Storage | Quota | Sync? | Persist | Use case |
|---|---|---|---|---|
localStorage | ~5MB | Sync | Yes | User prefs, theme, feature flags |
sessionStorage | ~5MB | Sync | Tab only | Form state trong tab |
IndexedDB | ~50% disk | Async | Yes | Large structured data, offline DB |
Cache API | ~50% disk | Async | Yes | Response objects (dùng với SW) |
cookies | ~4KB | Sync | Yes | Auth, sent với every request |
Quy tắc:
localStorageblock main thread — đừng đọc/ghi >100KB ở đó.- IndexedDB cho data có cấu trúc — query indexed, dùng
idblib để tránh callback hell. - Cache API + SW cho response — không phải data tự do.
- Cookie chỉ cho auth/session — dùng
Secure,HttpOnly,SameSite=Strict.
// src/lib/local-cache.ts
// Cache layer cho client-side data (e.g. user prefs, feature flags).
// In-memory + IDB fallback. Tránh localStorage cho data lớn vì block UI.
import { openDB, type IDBPDatabase } from 'idb';
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
let dbPromise: Promise<IDBPDatabase> | null = null;
function getDb() {
if (!dbPromise) {
dbPromise = openDB('app-cache', 1, {
upgrade(db) {
db.createObjectStore('kv');
},
});
}
return dbPromise;
}
const memory = new Map<string, CacheEntry<unknown>>();
export async function set<T>(key: string, value: T, ttlMs: number): Promise<void> {
const entry: CacheEntry<T> = { value, expiresAt: Date.now() + ttlMs };
memory.set(key, entry);
// IDB write async — không block UI.
const db = await getDb();
await db.put('kv', entry, key);
}
export async function get<T>(key: string): Promise<T | undefined> {
// Memory tier trước cho hit nóng (~0.01ms vs IDB ~5-20ms).
const mem = memory.get(key) as CacheEntry<T> | undefined;
if (mem && mem.expiresAt > Date.now()) return mem.value;
const db = await getDb();
const entry = (await db.get('kv', key)) as CacheEntry<T> | undefined;
if (!entry || entry.expiresAt <= Date.now()) return undefined;
memory.set(key, entry);
return entry.value;
}
9. Cache strategies — bảng quyết định
| Strategy | Khi nào dùng | Pros | Cons |
|---|---|---|---|
| Cache-first | Asset immutable (CSS/JS bundle với fingerprint) | Cực nhanh, offline OK | Stale data nếu config sai |
| Network-first | HTML, content thường xuyên đổi | Luôn fresh | Chậm khi network kém |
| Stale-while-revalidate | API content-heavy, list page | Fast + eventually fresh | User có thể thấy data cũ ~giây |
| Network-only | Auth, payment, write request | Không bao giờ stale | 0 offline, cao latency |
| Cache-only | Pre-cached offline pack | Hoạt động không cần network | Phải prepare trước |
| Race (network + cache) | UX critical (search suggestions) | Nhanh nhất có thể | 2x request |
Một implementation race strategy:
// src/lib/race-cache.ts
// Trả về kết quả nào xong trước giữa network và cache. Hữu ích cho
// UI critical như search-as-you-type: nếu cache miss, không phải chờ;
// nếu network slow, cache vẫn thắng.
export async function raceCache(
request: Request,
cache: Cache
): Promise<Response> {
const cached = cache.match(request);
const network = fetch(request);
// Promise.any resolve ở promise đầu tiên non-undefined non-error.
// cache.match resolve undefined nếu miss → loại khỏi race, network thắng.
return Promise.any([
cached.then((res) => res ?? Promise.reject(new Error('cache miss'))),
network,
]);
}
10. Auth & Caching — nơi dễ rò rỉ data nhất
Mọi thứ ở trên tối ưu cho tốc độ. Khi thêm authentication vào, ưu tiên đảo chiều: đúng người, đúng data quan trọng hơn nhanh. Đây cũng là chỗ caching gây ra bug đắt nhất — không phải trang chậm, mà là data của user A hiện trên màn hình user B. Phần này gom các tình huống auth × cache mà tôi (và rất nhiều site lớn) từng dẫm.
Một nguyên tắc xuyên suốt, nhắc lại từ §1: shared cache (CDN/proxy) không bao giờ được chứa data cá nhân hóa. Gần như mọi bug dưới đây là biến thể của việc vi phạm nó.
10.1. Leak giữa user qua shared cache
Kịch điển hình nhất:
1. User A login → GET /api/profile
2. Origin: 200, Cache-Control: public, max-age=3600 ← sai ngay đây
3. CDN cache response CỦA A
4. User B → GET /api/profile → CDN HIT → nhận profile của A
public trên endpoint trả data theo user là sai về bản chất. Data cá
nhân hóa chỉ được phép nằm ở private cache:
// ❌ Shared cache (CDN) sẽ phục vụ chéo user
res.setHeader('Cache-Control', 'public, max-age=3600');
// ✅ Chỉ browser của chính user đó được lưu
res.setHeader('Cache-Control', 'private, max-age=300');
private chặn CDN, nhưng chưa đủ khi nhiều user dùng chung một
browser — đó là 10.2.
10.2. Cùng browser, khác user (máy dùng chung)
private cho phép browser lưu. Trên máy thư viện / máy công ty, browser
cache sống qua cả phiên đăng nhập:
1. User A login máy chung → /dashboard cache (private, max-age=600)
2. A logout
3. User B mở /dashboard → browser HIT → thấy dashboard của A
Hai lớp phòng thủ:
(a) Validate thay vì tin freshness. Dùng no-cache + ETag cho data
nhạy cảm: browser vẫn cache để tiết kiệm payload, nhưng luôn hỏi lại
origin trước khi serve. Sau logout token chết → origin trả 401 → app
đẩy về login, không bao giờ serve bản cũ:
res.setHeader('Cache-Control', 'private, no-cache');
res.setHeader('ETag', etagFor(userId, dataVersion));
(b) Xoá sạch state phía client khi logout (xem 10.5).
10.3. Authorization header và shared cache
Nhắc lại từ §3.7: request có Authorization thì shared cache mặc định
không lưu. Cạm bẫy là khi ta cố tình bật public để “cache cho
nhanh” mà quên rằng mọi token giờ chia chung một entry:
// ❌ Mọi bearer token dùng CHUNG một cache entry → leak
res.setHeader('Cache-Control', 'public, max-age=600');
// ✅ Mặc định an toàn: để CDN bỏ qua, chỉ browser cache
res.setHeader('Cache-Control', 'private, max-age=300');
// ✅ Nếu response THẬT SỰ public mà request lại kèm token:
// tách entry theo token. Cẩn thận: 1 entry / token → có thể nổ số lượng.
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Vary', 'Authorization');
Vary: Authorization (cũng như Vary: Cookie, §3.5) tạo một cache entry
cho mỗi giá trị token — hit ratio thường tụt thê thảm. Chỉ dùng khi
response đồng nhất giữa user nhưng buộc phải gửi token.
10.4. Token hết hạn trong khi cache còn fresh
Một bug tinh vi: cache không biết gì về vòng đời token.
1. GET /api/cart, token còn hạn → 200, private, max-age=3600
2. Browser fresh trong 1 giờ
3. Token hết hạn sau 30 phút
4. Phút 45: GET /api/cart → browser serve từ cache (vẫn fresh)
→ KHÔNG gọi origin → token chết không bị phát hiện
5. User thao tác ghi → 401 đột ngột → bối rối
Cách chắc nhất là đừng để freshness vượt quá tuổi token. Hoặc canh
max-age theo thời gian token còn lại, hoặc đơn giản là no-cache để
mọi lần đều validate (origin tự kiểm token):
// Canh cache hết hạn TRƯỚC token vài chục giây
const safeTtl = Math.max(0, tokenRemainingSeconds - 30);
res.setHeader('Cache-Control', `private, max-age=${safeTtl}`);
// Hoặc: luôn validate, để origin quyết 200 / 304 / 401
res.setHeader('Cache-Control', 'private, no-cache');
Phía client, pattern bền là silent refresh có single-flight — nhiều request 401 cùng lúc chỉ kích hoạt một lần refresh, rồi cùng retry:
// src/lib/auth-fetch.ts
// fetch wrapper tự refresh token khi gặp 401. Single-flight: nếu đang
// refresh dở, các request khác CHỜ cùng promise thay vì refresh đua nhau
// (tránh refresh-storm làm xoay vòng token / rate-limit).
let refreshing: Promise<void> | null = null;
function refreshToken(): Promise<void> {
// Gọi lại nhiều lần khi đang refresh → trả về CÙNG một promise.
refreshing ??= fetch('/api/auth/refresh', { method: 'POST' })
.then((res) => {
if (!res.ok) throw new Error('refresh failed');
})
.finally(() => {
refreshing = null;
});
return refreshing;
}
export async function authFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
let res = await fetch(input, init);
if (res.status !== 401) return res;
try {
await refreshToken();
} catch {
// Refresh cũng fail → phiên thực sự chết. Đẩy về login.
window.location.replace('/login');
throw new Error('session expired');
}
// Retry đúng một lần sau khi token đã mới.
res = await fetch(input, init);
return res;
}
10.5. Cache không bị xoá sau logout
Logout thường chỉ huỷ session phía server. Browser cache, in-memory store, và bfcache (§4.2) vẫn giữ nguyên màn hình “đã đăng nhập” — đặc biệt khi user bấm Back.
Logout đúng phải dọn cả ba phía:
// src/lib/logout.ts
// Dọn TẤT CẢ tầng lưu trữ phía client. Thứ tự: gọi server huỷ session
// trước, rồi mới xoá local — để nếu request fail vẫn không kẹt UI cũ.
export async function logout(): Promise<void> {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} finally {
// 1. Cache API (Service Worker) — xoá mọi response đã lưu.
if ('caches' in window) {
const names = await caches.keys();
await Promise.all(names.map((n) => caches.delete(n)));
}
// 2. Client state (Zustand / Redux / React Query…).
// queryClient.clear(); useAuthStore.getState().reset(); …
// 3. Hard redirect (KHÔNG SPA navigation) để bỏ bfcache snapshot
// chứa UI cũ. replace() để Back không quay lại trang đã auth.
window.location.replace('/login');
}
}
Mạnh tay hơn, server có thể ra lệnh xoá bằng header Clear-Site-Data
(đã nhắc ở §4.4) ngay trên response logout:
HTTP/1.1 200 OK
Clear-Site-Data: "cache", "storage", "cookies"
Đồng thời, endpoint logout và endpoint kiểm tra phiên phải không cache để Back/refresh không tái dựng trạng thái cũ:
app.post('/api/auth/logout', async (req, res) => {
await invalidateSession(req.sessionId);
res.setHeader('Cache-Control', 'no-store');
res.clearCookie('session', { httpOnly: true, secure: true });
res.json({ ok: true });
});
// /api/auth/me gọi mỗi lần load → 401 thì redirect login.
app.get('/api/auth/me', requireAuth, (req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store');
res.json({ userId: req.user.id, role: req.user.role });
});
10.6. Cookie-based auth: bắt buộc tách theo phiên
Khi auth bằng session cookie, mọi response cá nhân hóa phải cho cache biết “đổi theo cookie”, nếu không một entry sẽ phục vụ nhầm phiên:
app.get('/api/dashboard', requireAuth, (req, res) => {
res.setHeader('Cache-Control', 'private, max-age=300');
res.setHeader('Vary', 'Cookie'); // mỗi cookie → entry riêng ở browser
res.json(getDashboard(req.user.id));
});
Lưu ý sắc thái: private đã chặn CDN, nên Vary: Cookie ở đây chỉ tác
động tới browser cache. Muốn CDN cache theo từng user (public +
Vary: Cookie) gần như luôn phản tác dụng — số entry bùng nổ, hit ratio
sập (chi tiết ở §12.1). Mặc định đúng cho trang có session: đừng để CDN
cache.
10.7. CORS nuốt mất conditional request
Với API khác origin (app.example.com gọi api.example.com), browser
chỉ đọc được các header mà server expose qua CORS. Quên expose
ETag → JS không thấy validator → tầng cache thủ công của bạn không bao
giờ gửi If-None-Match → revalidate hỏng, luôn full download:
// Cho phép browser ĐỌC các header cần cho revalidation cross-origin.
app.use(
cors({
origin: 'https://app.example.com',
exposedHeaders: ['ETag', 'Last-Modified', 'Cache-Control'],
}),
);
(HTTP cache tự động của browser vẫn revalidate được; vấn đề này chỉ chạm
tới code đọc header bằng fetch/Response.headers.)
11. Cache invalidation — bài toán khó nhất CS
Phil Karlton:
There are only two hard things in Computer Science: cache invalidation and naming things.
3 kỹ thuật phổ biến:
11.1. Fingerprint URLs (content-addressable)
Build tool nhúng hash của content vào URL: app.a3f9e7c.js. Asset đổi
nội dung → đổi URL. Cache cũ tự động mất hiệu lực vì không ai request
URL cũ nữa.
Cấu hình Vite/Astro mặc định đã làm việc này. Cộng với
Cache-Control: max-age=31536000, immutable là combo perfect cho
static asset.
11.2. Cache busting bằng query string
/api/products?v=42 — version bump mỗi deploy. Đơn giản nhưng có vài
gotcha:
- Một số corporate proxy cũ ignore query string trong cache key.
- Tạo cache pollution nếu bump version quá thường xuyên.
Modern hơn: dùng version trong URL path (/v3/api/products).
11.3. Active purge
Khi data đổi, gọi API CDN purge:
// src/lib/cache-invalidate.ts
// Trigger purge khi data đổi. Pattern: "purge tag, không purge URL"
// vì 1 thay đổi data thường ảnh hưởng nhiều URL (list page, detail
// page, search result, related products...).
import type { Tag } from './tags';
export async function purgeByTag(tags: Tag[]): Promise<void> {
// Purge non-blocking — không await response. Nếu CDN chậm, không
// delay user write request. Worst case: cache cũ thêm vài giây.
await Promise.allSettled(
tags.map((tag) =>
fetch(`https://api.fastly.com/service/${SERVICE_ID}/purge/${tag}`, {
method: 'POST',
headers: { 'Fastly-Key': process.env.FASTLY_API_KEY! },
})
)
);
}
12. Pitfalls — những sai lầm tôi đã trả giá
12.1. Vary: Cookie trên CDN
Cookie có session ID → mỗi user 1 cache entry → CDN cache hit ratio ~0%. Origin chịu toàn bộ load. Fix: trước khi forward về origin, strip cookie ở edge với asset tĩnh, hoặc cache key whitelist chỉ cookie nào thực sự ảnh hưởng response.
12.2. Cache-Control: public trên trang có session
Edge cache HTML chứa username, email. User B request cùng URL → nhận
cache của user A. Đây là data leak nghiêm trọng, đã từng xảy ra ở
nhiều site lớn (Steam 2015, Pizza Hut, Vodafone…). Rule: trang nào có
session → bắt buộc private hoặc dynamic không cache.
12.3. SW không update sau deploy
User cũ vẫn dùng JS cũ trong khi backend đã thay đổi schema → API incompat → blank page. Chiến lược:
- HTML luôn
network-first— đảm bảo user nhận shell mới sau deploy. - SW lifecycle:
skipWaiting()+clients.claim()để rollover nhanh. - Có endpoint
/version.jsonđể client check + force reload nếu version mismatch (defense in depth).
12.4. ETag không stable giữa nhiều origin server
Origin có 2 instance, mỗi instance hash file theo timestamp khác → cùng URL nhưng 2 ETag khác nhau → revalidate fail liên tục, browser luôn re-download. Fix: hash từ content, không phải metadata file.
12.5. no-store cho HTML → tắt bfcache
Đã nói ở §4.2. Lặp lại vì quá phổ biến. Dùng no-cache chứ đừng
dùng no-store cho HTML.
12.6. Image với cookie domain
Asset host trên cùng cookie domain → mỗi image request gửi 4KB cookie
→ tốn bandwidth + CDN không cache (vì Vary: Cookie ngầm). Fix: serve
asset từ cookieless subdomain (cdn.example.com) hoặc hoàn toàn
domain khác.
12.7. Cache key collision — bỏ sót query/header phân biệt
Hai resource khác nhau cùng map vào một cache key → cache phục vụ nhầm. Hay gặp khi proxy/cache layer không đưa query string (hoặc header phân biệt nội dung) vào key:
GET /api/list?page=1 ─┐
GET /api/list?page=2 ─┴─► cùng key "/api/list" → user luôn thấy page 1
GET /img (Accept: webp) ─┐
GET /img (Accept: png) ─┴─► cùng key → trình duyệt cũ nhận webp, vỡ
Hai phía cần khớp nhau: (1) server khai báo đúng chiều biến thiên bằng
Vary (§3.5) cho header, và (2) cache layer đưa query param phân biệt
nội dung vào key. Mặt trái cũng đúng — thừa thứ trong key (cookie rác,
utm_*) thì băm nhỏ cache, sập hit ratio; normalize key ở edge để gộp
lại (xem §5.1).
13. Cấu hình thực tế cho từng loại resource
Cheat sheet để copy-paste:
| Resource | Cache-Control | Note |
|---|---|---|
| HTML shell | no-cache | Revalidate mỗi request |
| JS/CSS bundle (fingerprint) | public, max-age=31536000, immutable | 1 năm, không revalidate |
| Image (fingerprint) | public, max-age=31536000, immutable | Như trên |
| Image (no fingerprint) | public, max-age=86400, stale-while-revalidate=604800 | 1 ngày fresh, 1 tuần stale |
| Font (woff2) | public, max-age=31536000, immutable | Font hiếm khi đổi |
| API public list | public, max-age=30, s-maxage=60, stale-while-revalidate=600 | SWR pattern |
| API user-specific | private, max-age=0, must-revalidate | Browser only, ETag revalidate |
| API mutation (POST/PUT) | no-store | Không cache write |
| robots.txt / sitemap.xml | public, max-age=3600 | Cập nhật vừa phải |
Cấu hình Astro/Vite mặc định cho asset hash + Nginx/Cloudfront serving:
# /etc/nginx/conf.d/cache.conf
# 2 location block riêng vì hash assets có URL pattern riêng.
location ~* /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
# API forward về Node, để upstream control header.
location /api/ {
proxy_pass http://localhost:3000;
# Strip Set-Cookie ở response để CDN trên front có thể cache GET.
proxy_hide_header Set-Cookie;
}
14. Speculative loading — nạp cache trước khi cần
Mọi thứ ở trên trả lời “làm sao tái dùng cái đã tải”. Speculative loading lật ngược câu hỏi: tải sẵn cái sắp cần, để khi cần thì cache đã nóng. Đây là tầng tối ưu tiếp theo sau khi đã cache tốt.
14.1. Resource hints — gợi ý cho trang hiện tại
<!-- Mở sẵn kết nối (DNS + TCP + TLS) tới origin bên thứ ba -->
<link rel="preconnect" href="https://api.example.com" />
<link rel="dns-prefetch" href="https://api.example.com" />
<!-- Tải SỚM resource quan trọng của TRANG NÀY, ưu tiên cao -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin />
<!-- Tải resource cho LẦN ĐIỀU HƯỚNG SAU, ưu tiên thấp (idle) -->
<link rel="prefetch" href="/next-page-data.json" as="fetch" />
Phân biệt dễ nhầm:
preload: “tôi chắc chắn cần file này cho trang hiện tại, hãy tải sớm”. Tải sai/không dùng trong vài giây → browser cảnh báo lãng phí.prefetch: “user có thể đi tới đây tiếp”, tải lúc rảnh, nhét vào HTTP cache (prefetch cache) chờ sẵn.preconnect: không tải gì, chỉ bắt tay sẵn — tiết kiệm 100–300ms cho request đầu tới domain đó.
14.2. Speculation Rules API — prefetch & prerender thế hệ mới
<link rel="prerender"> cũ đã bị Chrome khai tử. Thay thế là
Speculation Rules API: một block JSON khai báo URL (hoặc pattern) mà
browser nên prefetch hoặc prerender nguyên trang trong nền.
<script type="speculationrules">
{
"prerender": [
{
"where": { "href_matches": "/product/*" },
"eagerness": "moderate"
}
],
"prefetch": [
{
"where": { "selector_matches": "a.nav-link" },
"eagerness": "conservative"
}
]
}
</script>
prerendertải và render nguyên trang trong một tab ẩn — chạy cả JS, fetch cả subresource. Khi user click, trang hiện gần như tức thì (đổi tab ẩn thành tab thật). Khác<link rel="prerender">cũ: nó render đầy đủ và không bịCache-Controlchặn.eagernesstách “khi nào đoán” khỏi “đoán URL nào”:conservative(chỉ khi pointerdown/click),moderate(khi hover ~200ms),eager/immediate(ngay khi thấy rule). Càng hung hăng càng nhanh nhưng càng phí băng thông/CPU nếu đoán sai.- Có thể giao bằng
Speculation-RulesHTTP header thay vì nhúng inline → CDN bơm vào mà không sửa HTML. No-Vary-Search: báo cho browser rằng các query param nào đó (vdutm_*,?ref=) không đổi nội dung → một prefetch dùng lại được cho nhiều URL chỉ khác param. Rất hợp với landing page nhiều UTM.
Astro hỗ trợ prerender qua Speculation Rules từ 4.2 (client prerender), tự fallback về
prefetchthường cho browser chưa hỗ trợ — bật được chỉ bằng config, không phải viết tay JSON.
14.3. 103 Early Hints — dùng thời gian “server đang nghĩ”
Khi origin nhận request cho một trang nặng, nó tốn thời gian query DB,
render… Trong khoảng think-time đó, đường truyền tới browser đang
rảnh. 103 Early Hints (RFC 8297) tận dụng: server gửi một response
sơ bộ 103 chứa Link hint trước khi response 200 cuối cùng sẵn
sàng, để browser bắt đầu tải CSS/font/preconnect ngay.
HTTP/1.1 103 Early Hints
Link: </_astro/app.css>; rel=preload; as=style
Link: <https://fonts.example.com>; rel=preconnect
... (server vẫn đang render) ...
HTTP/1.1 200 OK
Content-Type: text/html
...
Khác biệt cốt lõi với Speculation Rules: Early Hints tăng tốc trang
hiện tại (lấp think-time của TTFB), còn Speculation Rules tăng tốc
trang kế tiếp. Hai cái bù nhau, không thay thế nhau. Early Hints cần
HTTP/2 hoặc HTTP/3; Safari hiện chỉ áp dụng cho preconnect. Đây cũng là
sự thay thế chính thức cho HTTP/2 Server Push (đã bị Chrome gỡ bỏ vì
khó dùng đúng và hay đẩy thừa thứ browser đã cache).
15. Đo lường & debugging — không đo thì không tối ưu được
3 metric bắt buộc theo dõi:
- Cache hit ratio ở CDN dashboard. Mục tiêu: 90%+ cho asset, 70%+ cho HTML, 50%+ cho API.
- Time to First Byte (TTFB): phản ánh edge serve nhanh hay phải fall back về origin. Mục tiêu: <200ms p75.
- Repeat view performance trong WebPageTest hoặc PageSpeed Insights — đại diện trải nghiệm user quay lại.
Quan sát từ DevTools:
Network panel → Size column:
"(disk cache)" → HTTP cache hit (disk tier)
"(memory cache)" → HTTP cache hit (RAM tier, tab session)
"(ServiceWorker)" → SW cache hit
"(prefetch cache)" → speculation hit từ <link rel="prefetch">
số bytes thực → network hit (revalidate hoặc full download)
Cột Status bổ trợ cho Size: 200 xám/italic = serve từ cache; 200 đen
= tải mới từ mạng; 304 = đã revalidate, dùng lại body cũ. Tick
Disable cache chỉ có hiệu lực khi DevTools đang mở — đừng nhầm nó với
hành vi production.
15.1. Soi header bằng cURL
DevTools tiện nhưng cURL mới là cách nhanh nhất để kiểm chứng chính xác header origin/CDN trả ra, không bị browser cache che mắt:
# Xem toàn bộ response header (HEAD request)
curl -sI https://example.com/api/data
# Conditional request: mô phỏng browser revalidate bằng ETag
# → kỳ vọng 304 nếu chưa đổi
curl -sI -H 'If-None-Match: "abc123"' https://example.com/api/data
# Ép bỏ qua cache trung gian (gửi Cache-Control của REQUEST)
curl -sI -H 'Cache-Control: no-cache' https://example.com/api/data
# Lọc nhanh các header caching quan trọng
curl -sI https://example.com/api/data \
| grep -iE 'cache-control|etag|age|vary|expires|last-modified|cf-cache-status|x-cache'
Age, x-cache (CloudFront/Fastly), cf-cache-status (Cloudflare) cho
biết request trúng edge hay rớt về origin — số liệu vàng khi debug
“sao response này stale/nhanh/chậm bất thường”.
15.2. Soi bằng JavaScript
Khi cần kiểm tra ngay trong app (hoặc tự động hoá trong test), đọc thẳng
header từ Response và tính phần freshness còn lại:
// src/lib/debug-cache.ts
// In ra các header caching + freshness còn lại của một URL. Dùng trong
// console hoặc smoke test để xác nhận config trước khi ship.
export async function debugCacheHeaders(url: string): Promise<void> {
const res = await fetch(url, { cache: 'no-store' }); // bỏ qua cache để đọc header gốc
const cacheControl = res.headers.get('cache-control') ?? '';
const maxAge = Number(cacheControl.match(/max-age=(\d+)/)?.[1] ?? 0);
const age = Number(res.headers.get('age') ?? 0);
const remaining = maxAge - age; // < 0 nghĩa là đã stale khi vừa nhận
console.table({
status: res.status,
cacheControl,
etag: res.headers.get('etag'),
age,
'remaining(s)': remaining,
});
}
16. Kết luận — checklist trước khi ship
- Static asset có fingerprint chưa? →
immutable, max-age=31536000. - HTML shell →
no-cache(không phảino-store). - API public → SWR pattern + ETag.
- API private →
private, max-age=0+ ETag. - CDN có strip tracking params không? Nếu chưa, hit ratio đang thấp hơn nó có thể.
- bfcache có active không? Test bằng
e.persisted. - SW có chiến lược version + cleanup? Hay đang fill disk vô tận?
- Có monitor cache hit ratio chưa? < 70% là red flag.
- Trang có session không bị
public? Audit lần cuối. - Image / font có ở cookieless domain? Mỗi cookie là 4KB lãng phí.
- Đã nạp sẵn cache cho navigation kế tiếp chưa? Speculation Rules
(prefetch/prerender) +
103 Early Hintscho trang quan trọng. - Origin có shield/tiered cache không? Một spike viral không nên đập thẳng vào origin — coalescing + shield là phao cứu sinh.
- Data cá nhân hóa có lọt
publickhông? Endpoint auth phảiprivate/no-store, canhmax-age≤ tuổi token, và logout xoá sạch cache phía client (§10).
Cache là chỗ trade-off giữa freshness (data mới đến tay user nhanh) và performance (request không cần gửi). Không có cấu hình “đúng” universal — phụ thuộc vào content nào, audience nào, business constraint nào. Nhưng các nguyên tắc trong bài này là baseline mọi frontend system đều cần.
Cached request không phải là request fast — nó là request không tồn tại. Đó là level optimization mà mọi CDN, mọi bundler tối ưu kế tiếp đều phải vác mặt nhìn lên.
Tham khảo:
- RFC 9111 — HTTP Caching
- RFC 9213 — CDN-Cache-Control
- MDN — HTTP caching
- MDN — Service Worker API
- web.dev — Back/forward cache
- Fastly — Surrogate-Control header
- Cloudflare — Cache everything
Reference đầy đủ — HTTP Caching cheat sheet (bấm để mở)
Mục Lục
- Tổng quan kiến trúc
- Vòng đời của một request
- Cache-Control — Từng Directive Chi Tiết
- Các header liên quan: ETag, Last-Modified, Vary, Age
- Cơ chế Validation (304 Not Modified)
- Các tầng cache
- Chiến lược caching theo loại resource
- Auth & Caching Issues — Chi Tiết
- Các lỗi caching phổ biến
- Debugging
1. Tổng Quan Kiến Trúc
Khi browser gửi một request, response có thể đi qua nhiều tầng cache trước khi chạm đến origin server:
[Browser]
│
│ (1) Browser Cache — bộ nhớ local của browser
│
↓
[Proxy / CDN]
│ ── Cloudflare, Fastly, AWS CloudFront, Nginx proxy...
│ Lưu bản copy của response để phục vụ nhiều user
│
↓
[Load Balancer]
│
↓
[Origin Server]
│
│ (optional) Application-level cache: Redis, Memcached
│
↓
[Database]Ba tầng quan trọng nhất từ góc độ HTTP headers:
- Browser Cache: Chỉ phục vụ 1 user (private). Browser tự quản lý.
- Shared/Proxy Cache: Phục vụ nhiều user. CDN hoặc reverse proxy quản lý.
- Origin Server: Nơi dữ liệu gốc tồn tại.
HTTP headers là “hợp đồng” giữa server và các tầng cache này — quy định ai được phép cache, bao lâu, và điều kiện nào thì phải revalidate.
2. Vòng Đời Của Một Request
Lần đầu tiên (Cold Cache)
Browser Server
│──── GET /api/data ─────────────────────→│
│ │
│←── 200 OK ────────────────────────────── │
│ Cache-Control: max-age=3600 │
│ ETag: "abc123" │
│ Body: {...} │
│ │
│ Browser lưu response vào cache │
│ Ghi nhận: cached_at = now │
│ max_age = 3600 │
│ etag = "abc123" │Trong thời gian còn “fresh” (ví dụ 30 phút sau)
Browser Server
│ Kiểm tra: now - cached_at = 30 min │
│ max-age = 60 min │
│ → CHƯA expire │
│ │
│ Trả thẳng từ cache, KHÔNG gửi request │
│ DevTools sẽ hiển thị: "from disk cache" │Sau khi expire (ví dụ 2 giờ sau)
Browser Server
│──── GET /api/data ─────────────────────→│
│ If-None-Match: "abc123" │
│ (conditional request) │
│ │
│ Trường hợp 1: Data không đổi │
│←── 304 Not Modified ────────────────────│
│ (Không có body) │
│ Browser dùng tiếp response cũ │
│ │
│ Trường hợp 2: Data đã thay đổi │
│←── 200 OK ──────────────────────────────│
│ ETag: "xyz789" (ETag mới) │
│ Body: {...} (Content mới) │
│ Browser thay thế cache cũ │Khi dùng no-cache, step 2 bị bỏ qua — browser luôn gửi conditional request lên server, dù cache chưa expire.
3. Cache-Control — Từng Directive Chi Tiết
Cache-Control có thể xuất hiện trong cả response header (server → client) và request header (client → server). Phần lớn bạn sẽ cấu hình ở response.
3.1 max-age=<seconds>
Ý nghĩa: Response được coi là “fresh” trong N giây kể từ khi được tạo ra trên server (thời điểm Date header).
Cơ chế cụ thể:
Server response lúc 10:00:00:
Date: Mon, 08 Jun 2026 10:00:00 GMT
Cache-Control: max-age=3600
Browser logic:
fresh_until = Date + max-age
= 10:00:00 + 3600s
= 11:00:00
Nếu request đến lúc 10:30 → fresh → serve from cache, NO request
Nếu request đến lúc 11:30 → stale → gửi conditional request lên serverLưu ý quan trọng: max-age tính từ thời điểm response được tạo trên server (Date header), không phải thời điểm browser nhận được. Nếu response đi qua CDN mất 10 giây, browser chỉ còn max-age - 10 giây của freshness.
Server tạo response CDN nhận Browser nhận
10:00:00 10:00:05 10:00:10
|___________________________|____________|
5 giây 5 giây
max-age=3600
→ Browser fresh_until = 10:00:10 + (3600 - 10) = 10:59:00?
Không! fresh_until = Date + max-age = 10:00:00 + 3600 = 11:00:00
→ Browser có đúng 3600s từ thời điểm server tạo ra responseHeader Age sẽ cho browser biết response đã “sống” được bao lâu rồi (xem mục 4).
3.2 s-maxage=<seconds>
Ý nghĩa: Giống max-age nhưng chỉ áp dụng cho shared cache (CDN, proxy). Browser bỏ qua s-maxage.
Cache-Control: max-age=60, s-maxage=86400- Browser: Cache 60 giây
- CDN/Proxy: Cache 86400 giây (1 ngày)
Tại sao cần s-maxage?
CDN phục vụ hàng triệu user — việc giữ cache lâu hơn giảm tải rất nhiều cho origin. Browser cache chỉ phục vụ 1 user nên không cần lâu.
// Ví dụ: News article
// - CDN cache 1 ngày (article không đổi thường xuyên)
// - Browser cache 1 phút (để user Ctrl+F5 vẫn thấy bản mới nếu cần)
res.set('Cache-Control', 'public, max-age=60, s-maxage=86400');3.3 public
Ý nghĩa: Cho phép mọi tầng cache (browser, CDN, proxy, intermediate cache) lưu response này.
Khi nào cần thiết?
Normally, nếu response không có header nào hoặc có Authorization header, shared cache sẽ không cache theo mặc định. public override rule đó.
GET /api/data
Authorization: Bearer token123 ← có header này
→ Mặc định CDN KHÔNG cache (vì có Authorization)
→ Nếu thêm Cache-Control: public → CDN được phép cache
Dùng khi: token dùng để auth request nhưng response trả ra là public data
(Ví dụ: signed URL để fetch resource nhưng resource tự nó là public)3.4 private
Ý nghĩa: Chỉ browser được phép cache. CDN, proxy, shared cache KHÔNG được cache.
Cache-Control: private, max-age=600Browser → CDN → Server
↓
200 OK + private
↓
CDN: "private → không cache, forward về browser"
Browser: "private OK → tôi cache được"
Kết quả: Chỉ browser cache response nàyQuan trọng cho auth: Dữ liệu user-specific (profile, inbox, cart) phải dùng private. Nếu dùng public, CDN có thể phục vụ dữ liệu của User A cho User B.
3.5 no-cache
Ý nghĩa: Cache vẫn được phép lưu response, nhưng mỗi lần dùng phải revalidate với server trước.
Cái tên rất misleading — no-cache không có nghĩa là “không cache”. Nó có nghĩa là “cache nhưng luôn hỏi lại server”.
no-cache flow:
Browser request → Cache có response → Gửi conditional request:
If-None-Match: "abc123"
lên server
Server:
→ Data chưa đổi: 304 Not Modified (không body, nhanh)
→ Data đã đổi: 200 OK (body mới)So sánh với max-age:
max-age=3600:
- 0-3600s: Browser KHÔNG request, dùng thẳng cache
- >3600s: Browser gửi conditional request
no-cache:
- Mọi lúc: Browser LUÔN gửi conditional request
- Nếu 304: dùng cache
- Nếu 200: dùng response mớiKhi nào dùng: HTML, trang index, bất cứ thứ gì cần đảm bảo luôn fresh nhưng vẫn muốn tận dụng 304 để tiết kiệm bandwidth.
3.6 no-store
Ý nghĩa: Tuyệt đối không lưu response vào bất kỳ cache nào. Mỗi request đều phải download full response.
no-store flow:
Browser request → Không kiểm tra cache → Fetch full response
↑ bỏ qua hoàn toàn ↑ luôn có bodyPhân biệt no-cache vs no-store:
Lưu vào cache? Revalidate? Có body?
no-cache Có Luôn luôn Chỉ khi 200
no-store Không N/A Luôn luônKhi nào dùng: Dữ liệu cực kỳ nhạy cảm — bank transaction, OTP, dữ liệu y tế. Mọi thứ mà việc lưu trên disk của user đã là vấn đề bảo mật.
3.7 must-revalidate
Ý nghĩa: Khi cache đã stale (hết hạn), KHÔNG được phép dùng stale response — phải revalidate với server. Nếu server không reach được → trả lỗi (504 Gateway Timeout).
Tại sao cần? Theo HTTP spec, cache được phép trả stale response khi server không available (stale-if-error). must-revalidate tắt behavior đó.
max-age=3600 (không có must-revalidate):
- Cache expired + server down → Browser có thể dùng stale response
- Hành vi này tùy browser implement
max-age=3600, must-revalidate:
- Cache expired + server down → 504 Gateway Timeout
- Không bao giờ dùng stale responseKhác với no-cache:
no-cache: Revalidate kể cả khi CHƯA stale
must-revalidate: Chỉ enforce khi ĐÃ stale (đã qua max-age)3.8 proxy-revalidate
Ý nghĩa: Giống must-revalidate nhưng chỉ áp dụng cho shared cache (CDN, proxy). Browser không bị ảnh hưởng.
Cache-Control: max-age=600, proxy-revalidate- Browser: Cache 10 phút, sau đó có thể dùng stale (không bắt buộc revalidate)
- CDN: Cache 10 phút, sau đó BẮT BUỘC revalidate với origin
3.9 immutable
Ý nghĩa: Nói với browser rằng response này sẽ không bao giờ thay đổi trong thời gian max-age. Browser sẽ không revalidate dù user Ctrl+F5.
Cache-Control: public, max-age=31536000, immutableTại sao cần immutable?
Không có immutable:
User Ctrl+F5 → Browser gửi conditional request (If-None-Match)
→ Server trả 304 → Waste 1 RTT cho file không bao giờ đổi
Có immutable:
User Ctrl+F5 → Browser: "immutable + chưa expire → không cần check"
→ Serve từ cache luônChỉ dùng với content-hashed assets. Nếu file đổi, filename đổi → URL mới → browser fetch lại.
/assets/app.a1b2c3d4.js → Cache-Control: immutable
/assets/app.e5f6g7h8.js → URL mới → browser fetch fresh3.10 stale-while-revalidate=<seconds>
Ý nghĩa: Khi cache đã stale nhưng chưa quá stale-while-revalidate giây, browser có thể trả response cũ ngay lập tức trong khi revalidate ở background.
Cache-Control: max-age=60, stale-while-revalidate=3600
Timeline:
0s - 60s: Fresh → Serve từ cache, không request
60s - 3660s: Stale, nhưng trong SWR window
→ Trả cache cũ ngay (người dùng không chờ)
→ Background: gửi request revalidate
→ Lần request tiếp theo sẽ có dữ liệu mới
> 3660s: Phải fetch fresh, blockingUse case điển hình: News feed, product listings — data có thể hơi cũ 1-2 phút không sao, nhưng không muốn user chờ loading spinner mỗi lần vào trang.
3.11 stale-if-error=<seconds>
Ý nghĩa: Nếu server trả lỗi (5xx), browser được phép dùng stale cache trong N giây.
Cache-Control: max-age=3600, stale-if-error=86400
Nếu origin server down:
→ Browser dùng stale cache (dù đã cũ đến 1 ngày)
→ Thay vì hiện lỗi trắng trangKết hợp với must-revalidate sẽ override điều này (không cho dùng stale dù server lỗi).
3.12 no-transform
Ý nghĩa: Cấm proxy/CDN transform response (không compress, không resize ảnh, không modify body).
Cache-Control: public, max-age=3600, no-transformDùng khi response phải giữ nguyên chính xác (checksum-sensitive, encrypted content).
3.13 Tổng Hợp Directive
┌──────────────────────┬─────────────────┬───────────────────────────────────────────────────┐
│ Directive │ Áp dụng cho │ Hành vi │
├──────────────────────┼─────────────────┼───────────────────────────────────────────────────┤
│ max-age=N │ Browser + CDN │ Fresh trong N giây (tính từ Date header) │
│ s-maxage=N │ CDN only │ Fresh trong N giây chỉ tại CDN │
│ public │ CDN + Browser │ Bất kỳ cache nào đều được lưu │
│ private │ Browser only │ Chỉ browser được lưu, CDN không được │
│ no-cache │ Browser + CDN │ Lưu được, nhưng luôn phải revalidate trước dùng │
│ no-store │ Browser + CDN │ Tuyệt đối không lưu vào bất kỳ cache nào │
│ must-revalidate │ Browser + CDN │ Khi stale, bắt buộc revalidate, không dùng stale │
│ proxy-revalidate │ CDN only │ Như must-revalidate nhưng chỉ cho CDN │
│ immutable │ Browser │ Không revalidate kể cả khi user force refresh │
│ stale-while-reval. │ Browser + CDN │ Trả stale ngay + revalidate background │
│ stale-if-error │ Browser + CDN │ Dùng stale nếu server trả lỗi │
│ no-transform │ CDN/Proxy │ Không cho phép proxy modify response │
└──────────────────────┴─────────────────┴───────────────────────────────────────────────────┘4. Các Header Liên Quan
4.1 ETag (Entity Tag)
Server tạo ra một “fingerprint” cho response. Khi content thay đổi, ETag thay đổi.
Strong ETag (ETag: "abc123"): Byte-for-byte identical. Server chỉ trả 304 nếu response hoàn toàn giống nhau.
Weak ETag (ETag: W/"abc123"): Semantically equivalent. Server trả 304 nếu “nội dung về mặt nghĩa là giống nhau”, dù bytes có thể khác (ví dụ: whitespace thay đổi, timestamp trong JSON thay đổi).
// Server tính ETag như thế nào?
import crypto from 'crypto';
function generateETag(data) {
return crypto
.createHash('md5')
.update(JSON.stringify(data))
.digest('hex');
}
// Hoặc dùng file modification time + size
function fileETag(stat) {
return `"${stat.mtime.getTime().toString(16)}-${stat.size.toString(16)}"`;
}Conditional request headers tương ứng:
If-None-Match: "abc123"— dùng với GET (check if stale)If-Match: "abc123"— dùng với PUT/PATCH (optimistic locking, tránh lost update)
4.2 Last-Modified và If-Modified-Since
Alternative cho ETag, dùng timestamp thay vì hash.
Server → Browser:
Last-Modified: Mon, 08 Jun 2026 10:00:00 GMT
Browser → Server (sau khi cache stale):
If-Modified-Since: Mon, 08 Jun 2026 10:00:00 GMT
Server:
- File chưa đổi sau 10:00 → 304 Not Modified
- File đổi sau 10:00 → 200 OK + Last-Modified mớiETag vs Last-Modified:
ETag Last-Modified
Độ chính xác Cao (hash) Thấp (1s granularity)
Overhead Cần tính hash Dùng mtime có sẵn
Dynamic content Dễ implement Khó (content tính toán lúc nào?)
Files tĩnh OK OK (dùng file mtime)Nếu server trả cả hai, browser gửi cả hai trong conditional request. Server ưu tiên ETag.
4.3 Vary Header
Nói với cache: “Response này thay đổi tùy theo giá trị của header X”. Mỗi giá trị header khác nhau → cache entry khác nhau.
Server response:
Vary: Accept-Encoding
→ Cache tạo 2 entries riêng biệt:
- Request với Accept-Encoding: gzip → cache entry A
- Request với Accept-Encoding: br → cache entry B// Ví dụ thực tế: API trả JSON hoặc XML tùy Accept
res.set('Vary', 'Accept');
// Request 1: Accept: application/json → cache A (JSON)
// Request 2: Accept: application/xml → cache B (XML)Dùng với auth (chi tiết ở mục 8):
Vary: Cookie → Mỗi cookie khác nhau → cache entry riêng
Vary: Authorization → Mỗi token khác nhau → cache entry riêngPitfall: Vary: User-Agent tạo hàng nghìn cache entries cho mỗi browser variant. Tránh dùng nếu không cần thiết.
4.4 Age Header
Header do CDN/proxy thêm vào, cho biết response đã ở trong cache bao lâu (tính bằng giây).
Browser nhận response:
Cache-Control: max-age=3600
Age: 1200
→ Response đã cache ở CDN được 1200 giây
→ Browser chỉ còn 3600 - 1200 = 2400 giây fresh time
→ Browser tính: fresh_until = now + 2400sNếu Age > max-age → Response đã stale khi đến browser. Browser sẽ revalidate ngay.
4.5 Expires (Legacy)
Absolute timestamp thay vì relative seconds. Deprecated nhưng vẫn còn trên nhiều server cũ.
Expires: Mon, 08 Jun 2026 12:00:00 GMTVấn đề: Server và client clock có thể lệch nhau → tính toán sai.
Rule: Nếu cả Cache-Control và Expires cùng tồn tại, Cache-Control luôn thắng.
5. Cơ Chế Validation
Validation là quá trình browser hỏi server “resource này có thay đổi không?”. Nếu không → 304, dùng cache. Nếu có → 200, download mới.
Conditional Request Flow Đầy Đủ
Browser có cached response với:
ETag: "abc123"
Last-Modified: Mon, 08 Jun 2026 10:00:00 GMT
Cache-Control: no-cache (hoặc max-age đã expired)
Browser gửi:
GET /api/data HTTP/1.1
If-None-Match: "abc123" ← ETag check
If-Modified-Since: Mon, 08 Jun 2026 10:00:00 GMT ← timestamp check
Server xử lý:
1. Kiểm tra If-None-Match trước (ưu tiên hơn)
2. So sánh ETag hiện tại với "abc123"
3. Nếu giống → 304 (kể cả nếu timestamp khác)
4. Nếu khác → 200 với body mới
Khi server trả 304:
- KHÔNG có body (tiết kiệm bandwidth)
- Có thể update headers (Cache-Control mới, ETag mới)
- Browser dùng body từ cache cũ, headers từ 304 response mới
304 Response có thể chứa:
HTTP/1.1 304 Not Modified
Cache-Control: max-age=3600 ← Reset freshness timer
ETag: "abc123" ← Confirm ETag
(Không có body)Freshness vs Validation
Cache State Behavior
────────────────────────────────────────────────────────────
Fresh Serve from cache, NO request to server
Stale + ETag Send If-None-Match → 304 or 200
Stale + no ETag Send If-Modified-Since → 304 or 200
no-cache Always validate (dù vẫn "fresh" về mặt max-age)
no-store Never cache, always full download6. Các Tầng Cache
6.1 Memory Cache (Browser RAM)
- Nhanh nhất — trong RAM
- Bị xóa khi đóng tab hoặc browser
- Chrome hiển thị:
"(from memory cache)" - Thường chứa: resources đang được dùng trong tab hiện tại
6.2 Disk Cache (Browser Disk)
- Chậm hơn memory, nhưng persist qua restart
- Chrome hiển thị:
"(from disk cache)" - Controlled bởi
Cache-Controlheaders - Chrome location:
chrome://cache
6.3 Service Worker Cache
Khác với disk cache ở điểm: code của bạn kiểm soát hoàn toàn. Browser cache tự động theo HTTP headers. Service Worker cache thì bạn quyết định lưu gì, khi nào expire, strategy nào.
// Intercept mọi request
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((hit) => {
if (hit) return hit;
return fetch(event.request);
})
);
});Service Worker cache và HTTP cache hoạt động độc lập. Service Worker có thể quyết định bypass HTTP cache bằng:
fetch(request, { cache: 'no-store' }); // Bypass browser HTTP cache6.4 CDN / Shared Cache
Proxy đứng giữa browser và server. Một response được cache ở CDN có thể phục vụ cho hàng triệu user.
CDN cache
User A → GET /img/logo.png ─→ MISS → Origin
User B → GET /img/logo.png ─→ HIT ← CDN trả luôn (origin không biết)
User C → GET /img/logo.png ─→ HIT ← CDN trả luônCDN respect Cache-Control headers: public cho phép CDN cache, private không cho.
CDN cũng có thể override cache headers trong config — ví dụ Cloudflare có thể cache mọi thứ bất kể Cache-Control.
7. Chiến Lược Caching Theo Loại Resource
7.1 HTML Entry Point (index.html)
Không bao giờ cache, hoặc nếu cache thì phải revalidate mỗi lần:
res.set('Cache-Control', 'no-cache');
// Kết hợp với ETag để 304 nếu unchanged
// Hoặc an toàn hơn:
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
res.set('Pragma', 'no-cache'); // Legacy HTTP/1.0
res.set('Expires', '0');Lý do: HTML là entry point load tất cả JS, CSS. Nếu cache HTML cũ, user sẽ dùng app cũ kể cả sau khi deploy mới.
7.2 Static Assets có Content Hash (JS, CSS, Font)
// Webpack / Vite tạo: app.3f7a92bc.js
res.set('Cache-Control', 'public, max-age=31536000, immutable');Lý do: URL đã bao gồm hash của content. File đổi → hash đổi → URL đổi → browser tự fetch lại. URL cũ vẫn valid trong cache (rollback dễ dàng).
7.3 Ảnh và Media Tĩnh
// Ảnh có version trong URL (logo.v2.png)
res.set('Cache-Control', 'public, max-age=31536000, immutable');
// Ảnh không có version (avatar có thể đổi)
res.set('Cache-Control', 'public, max-age=3600');
res.set('ETag', etagFromFile);7.4 API — Public Data (Không cần auth)
// Product list, article list, categories...
res.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');Có thể thêm CDN cache dài hơn:
res.set('Cache-Control', 'public, max-age=60, s-maxage=3600');
// Browser: 1 phút
// CDN: 1 giờ7.5 API — User-Specific Data (Cần auth)
// Profile, cart, settings...
res.set('Cache-Control', 'private, no-cache');
res.set('ETag', generateETag(userData));
// → Browser cache, nhưng revalidate mỗi lần
// → 304 nếu không đổi (tiết kiệm bandwidth)
// → CDN không cache (private)7.6 API — Real-time Data
// Stock prices, live chat, notifications
res.set('Cache-Control', 'no-store');
// Mỗi request đều phải fetch fresh8. Auth & Caching Issues
Đây là phần dễ gây lỗi nhất. Caching và authentication tương tác theo nhiều cách không trực quan.
8.1 Issue: Shared Cache Leak Dữ Liệu Giữa Users
Scenario:
1. User A đăng nhập, request GET /api/profile
2. Server trả: 200 OK, Cache-Control: public, max-age=3600
3. CDN cache response của User A
4. User B (chưa đăng nhập, hoặc khác user) request GET /api/profile
5. CDN trả response của User A cho User B!Root cause: Dùng public cho endpoint trả user-specific data.
Fix:
// Nguy hiểm
res.set('Cache-Control', 'public, max-age=3600');
// Đúng: Private cache only
res.set('Cache-Control', 'private, max-age=300');
// CDN không cache, chỉ browser của user đóNhưng private chưa đủ nếu dùng session cookie (xem 8.2).
8.2 Issue: Cùng Browser, Khác User (Shared Device)
Scenario:
1. User A đăng nhập trên máy tính chung ở thư viện
2. Vào /dashboard → Browser cache response (Cache-Control: private, max-age=600)
3. User A logout
4. User B ngồi vào, vào /dashboard
5. Browser vẫn còn cache → Hiển thị dashboard của User A!Fix 1: Xóa cache khi logout
// Frontend logout handler
async function logout() {
await api.post('/auth/logout');
// Xóa cache bằng cách invalidate Service Worker cache
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
}
// Clear state management (Zustand, Redux)
useUserStore.getState().reset();
// Redirect
window.location.href = '/login';
// Hard redirect (không history.push) để browser không back được
}Fix 2: Dùng no-cache thay vì max-age cho sensitive data
res.set('Cache-Control', 'private, no-cache');
res.set('ETag', generateETag(userId + userDataVersion));
// Browser cache nhưng luôn revalidate
// Sau logout: token invalid → 401 → redirect loginFix 3: Include user identifier trong cache key (dùng Vary: Cookie)
res.set('Cache-Control', 'private, max-age=300');
res.set('Vary', 'Cookie');
// Mỗi session cookie khác → cache entry khác
// User B có session khác → cache miss → fetch fresh8.3 Issue: Authorization Header Và Shared Cache
Theo HTTP spec, nếu request có Authorization header, shared cache (CDN) mặc định không được cache response — trừ khi server explicit opt-in bằng Cache-Control: public.
Mặc định (không có explicit Cache-Control):
Request: GET /api/data + Authorization: Bearer token
→ CDN không cache (safe default)
Nếu thêm public:
Response: Cache-Control: public, max-age=3600
→ CDN cache response của token này
→ Danger: User B với token khác có thể nhận được response của User A
(nếu CDN không cache theo Vary: Authorization)Fix:
// Endpoint trả user-specific data + dùng Bearer token
// Sai — CDN cache tất cả bearer token dùng chung 1 entry
res.set('Cache-Control', 'public, max-age=600');
// Option 1: Private (CDN không cache)
res.set('Cache-Control', 'private, max-age=300');
// Option 2: Public + Vary theo Authorization
// Mỗi token → cache entry riêng
// Chỉ dùng khi response thực sự public nhưng request có token
res.set('Cache-Control', 'public, max-age=300');
res.set('Vary', 'Authorization');
// Cẩn thận: tạo rất nhiều cache entries (1 entry/token)8.4 Issue: Token Expire Trong Khi Cache Vẫn Fresh
Scenario:
1. User request /api/user/cart, token hợp lệ
2. Server: 200 OK, Cache-Control: private, max-age=3600
3. Browser cache, fresh_until = now + 3600s
4. Token expire sau 1800s (30 phút)
5. User request /api/user/cart lần 2 — cache vẫn fresh (1 tiếng)
6. Browser serve từ cache → Không gửi request → Token expire không được phát hiện
7. User thực hiện action khác cần token → 401 → ConfusionFix: Align max-age với token lifetime
// Nếu token expire sau 1800s (30 phút)
res.set('Cache-Control', `private, max-age=${tokenRemainingSeconds - 60}`);
// Cache expire 1 phút trước token expire → browser fetch lại trước khi token chết
// Hoặc đơn giản: max-age ngắn + no-cache
res.set('Cache-Control', 'private, no-cache');
// Browser luôn hit server → server luôn validate token
// 304 nếu data chưa đổi, 401 nếu token expirePattern tốt hơn: Silent token refresh kết hợp với cache
// axios interceptor
axiosInstance.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
try {
await refreshAccessToken(); // Refresh token
return axiosInstance(error.config); // Retry request
} catch (refreshError) {
logout(); // Refresh cũng fail → logout
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
});8.5 Issue: Cache Sau Khi Logout Không Bị Xóa
Logout chỉ invalidate server-side session hoặc token. Browser cache vẫn giữ nguyên.
Scenario:
1. User đăng nhập, browse quanh ứng dụng
2. Browser cache: /api/cart, /api/notifications, /api/profile
3. User logout → Server invalidate session
4. User nhấn Back button
5. Browser render từ cache → Page hiển thị như đang đăng nhập!
(Vì HTML/JS từ cache, API responses từ cache)Fix toàn diện:
// 1. Server: Endpoint logout trả no-store
app.post('/api/auth/logout', async (req, res) => {
await invalidateSession(req.sessionId);
res.set('Cache-Control', 'no-store');
// Clear auth cookie
res.clearCookie('session', { httpOnly: true, secure: true });
res.json({ success: true });
});
// 2. Server: Endpoint check auth status (không cache)
app.get('/api/auth/me', requireAuth, (req, res) => {
res.set('Cache-Control', 'no-cache, no-store');
res.json({ userId: req.user.id, role: req.user.role });
});
// Mỗi page load, app gọi /api/auth/me → nếu 401 → redirect login
// 3. Frontend: Clear state và cache
async function logout() {
try {
await axios.post('/api/auth/logout');
} finally {
// Clear Zustand state
useAuthStore.getState().clearUser();
useCartStore.getState().clearCart();
// Clear axios cache nếu có custom cache layer
axiosCache.clear();
// Invalidate React Query / SWR cache
queryClient.clear();
// Hard redirect (không SPA navigation)
window.location.replace('/login');
}
}8.6 Issue: CDN Cache Public API Sau Khi Data Thay Đổi
Scenario:
1. Admin cập nhật giá sản phẩm qua /admin/products/123
2. CDN đang cache /api/products/123, max-age=3600, còn 2 tiếng fresh
3. User request /api/products/123 → CDN trả giá cũ
4. User mua hàng với giá cũ → Conflict với databaseFix: Cache Invalidation / Purging
// Khi admin cập nhật:
app.put('/admin/products/:id', requireAdmin, async (req, res) => {
const product = await updateProduct(req.params.id, req.body);
// Purge CDN cache cho URL này
await cloudflare.purgeCache([`/api/products/${req.params.id}`]);
// Hoặc dùng Surrogate-Key để purge theo tag (xem dưới)
res.json(product);
});
// Surrogate-Key / Cache-Tag pattern:
// Server đánh tag cho response
app.get('/api/products/:id', (req, res) => {
res.set('Surrogate-Key', `product-${req.params.id} products`);
res.set('Cache-Control', 'public, max-age=3600');
res.json(product);
});
// Khi sản phẩm thay đổi, purge theo tag:
await cloudflare.purgeTag(`product-${productId}`);
// Invalidate tất cả URLs có Surrogate-Key chứa tag nàyHoặc dùng stale-while-revalidate để giảm risk:
// Chấp nhận data hơi cũ 1 phút
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
// CDN revalidate trong background → user ít khi thấy stale data8.7 Issue: Vary Header Và Cookie-Based Auth
Khi dùng session cookie để auth, mọi response cho user-specific data cần Vary: Cookie — nếu không, cache có thể phục vụ nhầm.
Không có Vary: Cookie:
User A (cookie: session=aaa) → GET /api/dashboard → 200, cache lưu
User B (cookie: session=bbb) → GET /api/dashboard → HIT (dữ liệu của User A!)
Có Vary: Cookie:
User A → cache entry cho cookie=aaa
User B → cache entry cho cookie=bbb (miss → fetch fresh)// Session-based auth
app.get('/api/dashboard', requireAuth, (req, res) => {
res.set('Cache-Control', 'private, max-age=300');
res.set('Vary', 'Cookie'); // Tạo separate cache entry per cookie
res.json(getDashboardData(req.user.id));
});Lưu ý: private + Vary: Cookie ở CDN level: private đã bảo CDN không cache → Vary: Cookie chỉ ảnh hưởng browser. Nếu muốn CDN cache per-user, dùng public + Vary: Cookie (nhưng thường tạo quá nhiều entries, không practical).
8.8 Issue: Conditional Request Bị Bypass Do CORS
Với cross-origin requests, browser đôi khi không gửi conditional headers (If-None-Match) vì cache entry không match origin.
App: app.example.com
API: api.example.com (khác origin)
Browser cache entry từ api.example.com/data với ETag "abc"
→ Cache entry gắn với origin api.example.com
→ Request từ app.example.com vẫn có thể trigger validation
(nhưng phụ thuộc vào CORS preflight và browser implementation)Fix: Đảm bảo CORS headers đúng và consistent:
app.use(
cors({
origin: 'https://app.example.com',
exposedHeaders: ['ETag', 'Cache-Control', 'Last-Modified'],
// Browser chỉ có thể đọc headers được expose
})
);9. Các Lỗi Caching Phổ Biến
9.1 Cache Stampede / Thundering Herd
Xảy ra khi cache expire đồng thời với nhiều request → tất cả hit origin server cùng lúc.
100 users request cùng 1 URL
Cache expire đúng lúc đó
→ 100 requests đến origin server cùng lúc
→ Server quá tải, database chếtFix:
// Pattern 1: Stale-While-Revalidate
res.set('Cache-Control', 'max-age=60, stale-while-revalidate=3600');
// Chỉ 1 background request thay vì 100 concurrent
// Pattern 2: Probabilistic Early Expiration (Jitter)
// Thay vì expire exact lúc max-age hết, expire random trong window
const jitter = Math.random() * 0.1 * maxAge; // 10% jitter
res.set('Cache-Control', `max-age=${Math.floor(maxAge - jitter)}`);
// Pattern 3: Cache Lock (server-side Redis)
async function getWithCacheLock(key, fetchFn, ttl) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, '1', 'EX', 5, 'NX');
if (!acquired) {
// Đợi lock release, retry
await sleep(100);
return getWithCacheLock(key, fetchFn, ttl);
}
try {
const data = await fetchFn();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
} finally {
await redis.del(lockKey);
}
}9.2 Cache Key Collision
Hai resources khác nhau nhưng cùng cache key → browser phục vụ nhầm.
GET /api/data?page=1 → cache key: /api/data
GET /api/data?page=2 → cache key: /api/data (nếu không include query string!)
→ User luôn thấy page 1Fix: Đảm bảo server trả đúng Vary headers, và cache layer bao gồm query params trong key.
9.3 Stale Cache Sau Deploy
Deploy app mới:
- index.html (không cache)
- app.newHash.js (cached lâu dài)
Nếu user có service worker cache cũ:
→ Service Worker serve app.oldHash.js
→ JavaScript cũ, API mới → IncompatibilityFix:
// Versioned Service Worker cache
const CACHE_VERSION = process.env.BUILD_ID; // Build ID thay đổi mỗi deploy
const CACHE_NAME = `app-cache-${CACHE_VERSION}`;
// Activate: Delete old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
)
);
});10. Debugging
Chrome DevTools — Network Tab
Cột Size:
"(from memory cache)" → RAM, không request, instant
"(from disk cache)" → Disk, không request, fast
304 → Revalidated, dùng cache, có request
200 (số byte) → Full download, có request
Cột Status:
200 (grey italic) → Served from cache
200 (đen) → Fresh download from network
304 → Not modified, dùng cache
Disable cache khi debug:
DevTools → Network → "Disable cache" checkbox
(Chỉ disable khi DevTools mở)
Xem headers:
Click vào request → Headers tab
Request Headers: If-None-Match, If-Modified-Since, Cache-Control
Response Headers: Cache-Control, ETag, Last-Modified, Vary, AgeKiểm Tra Cache Headers Bằng cURL
# Xem response headers
curl -I https://example.com/api/data
# Conditional request với ETag
curl -I -H 'If-None-Match: "abc123"' https://example.com/api/data
# Force bypass cache
curl -H 'Cache-Control: no-cache' https://example.com/api/data
# Verbose (xem cả request lẫn response headers)
curl -v https://example.com/api/data 2>&1 | grep -E "< |> "Kiểm Tra Từ JavaScript
async function debugCacheHeaders(url) {
const res = await fetch(url);
const headers = {
status: res.status,
cacheControl: res.headers.get('cache-control'),
etag: res.headers.get('etag'),
lastModified: res.headers.get('last-modified'),
vary: res.headers.get('vary'),
age: res.headers.get('age'),
expires: res.headers.get('expires'),
};
console.table(headers);
// Tính thời gian còn lại
const maxAge = parseInt(
headers.cacheControl?.match(/max-age=(\d+)/)?.[1] ?? 0
);
const age = parseInt(headers.age ?? 0);
const remaining = maxAge - age;
console.log(`Cache remaining: ${remaining}s (${Math.floor(remaining / 60)} min)`);
}Tóm Tắt Nhanh
Loại Resource Config Header
───────────────────────────────────────────────────────────────────────
index.html no-cache, no-store, must-revalidate
JS/CSS có content hash public, max-age=31536000, immutable
Ảnh tĩnh public, max-age=86400, + ETag
Public API (categories, news) public, max-age=3600, stale-while-revalidate=86400
CDN-optimized public API public, max-age=60, s-maxage=3600
User-specific data private, no-cache + ETag
Sensitive (token, OTP) no-store
Real-time (stock, live chat) no-storeNguyên tắc cốt lõi:
- HTML luôn
no-cache→ Đảm bảo deploy mới được nhận - Static assets dùng content hash → Cache vĩnh viễn an toàn
- User data dùng
private→ CDN không được cache - Sensitive data dùng
no-store→ Không bao giờ lưu đĩa - Dùng ETag +
no-cache→ Revalidate nhưng tiết kiệm bandwidth với 304 - Dùng
Vary: Cookie→ Mỗi user session một cache entry - Xóa cache khi logout → Bảo mật trên shared device
- Align
max-agevới token lifetime → Không serve stale auth data