jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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-ageno-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
publicCả 2Cho phép shared cache (CDN, proxy) lưu
privateBrowserChỉ browser được lưu, CDN phải bỏ qua
no-cacheCả 2Lưu được, nhưng phải revalidate trước khi serve
no-storeCả 2Không được lưu ở bất kỳ tầng nào
max-age=NCả 2Coi response là fresh trong N giây
s-maxage=NSharedOverride max-age cho shared cache
must-revalidateCả 2Hết hạn rồi không được serve stale, phải revalidate
immutableBrowserTrong max-age không cần revalidate ngay cả khi user reload
stale-while-revalidate=NCả 2Sau khi expire, vẫn serve stale trong N giây + refresh background
stale-if-error=NCả 2Nếu origin error, serve stale trong N giây
no-transformCả 2Cấ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 — ETagLast-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ính Age khi 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ần s-maxage.
  • CDN-Cache-Control: chuẩn hóa mới (RFC 9213), một số CDN ưu tiên trên Surrogate-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: 3600max-age: 3600đã 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ơn If-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-revalidate vs 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-revalidate cấ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:

LayerLifetimeSpeedTrigger
Memory cacheTab session~1msResource đã load lần này
Disk cache (HTTP)Persistent~10-50msCache-Control / Expires
Service WorkerProgrammable~10msCode điều khiển
BFCacheBack/forward nav~instantPage 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 unload event (ngừng đi, dùng pagehide thay).
  • 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.comsite-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:

TierDung lượng điển hìnhChính sách evict
Memory cacheVài chục MB / rendererLRU, xoá sạch khi đóng tab
Disk cache~vài % dung lượng đĩa, có capLRU theo last-access, ưu tiên giữ nóng
Cache API/IDBTheo 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 file immutable, max-age=1year vẫn có thể bị xoá sau vài ngày nếu user không ghé lại và cache đầy. immutable chỉ 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-Data header 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-Encoding toà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ếLatencyGranularityCost
URL purge~vài giâyPer-URLRẻ
Tag-based purge~vài giâyPer-resourceCần plan tốt
Soft purge0Toà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() (trong install): 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() (trong activate): 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ới skipWaiting().

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 trong activate.


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:

StorageQuotaSync?PersistUse case
localStorage~5MBSyncYesUser prefs, theme, feature flags
sessionStorage~5MBSyncTab onlyForm state trong tab
IndexedDB~50% diskAsyncYesLarge structured data, offline DB
Cache API~50% diskAsyncYesResponse objects (dùng với SW)
cookies~4KBSyncYesAuth, sent với every request

Quy tắc:

  • localStorage block main thread — đừng đọc/ghi >100KB ở đó.
  • IndexedDB cho data có cấu trúc — query indexed, dùng idb lib để 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

StrategyKhi nào dùngProsCons
Cache-firstAsset immutable (CSS/JS bundle với fingerprint)Cực nhanh, offline OKStale data nếu config sai
Network-firstHTML, content thường xuyên đổiLuôn freshChậm khi network kém
Stale-while-revalidateAPI content-heavy, list pageFast + eventually freshUser có thể thấy data cũ ~giây
Network-onlyAuth, payment, write requestKhông bao giờ stale0 offline, cao latency
Cache-onlyPre-cached offline packHoạt động không cần networkPhả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 });
});

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á

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.

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:

ResourceCache-ControlNote
HTML shellno-cacheRevalidate mỗi request
JS/CSS bundle (fingerprint)public, max-age=31536000, immutable1 năm, không revalidate
Image (fingerprint)public, max-age=31536000, immutableNhư trên
Image (no fingerprint)public, max-age=86400, stale-while-revalidate=6048001 ngày fresh, 1 tuần stale
Font (woff2)public, max-age=31536000, immutableFont hiếm khi đổi
API public listpublic, max-age=30, s-maxage=60, stale-while-revalidate=600SWR pattern
API user-specificprivate, max-age=0, must-revalidateBrowser only, ETag revalidate
API mutation (POST/PUT)no-storeKhông cache write
robots.txt / sitemap.xmlpublic, max-age=3600Cậ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>
  • prerender tả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-Control chặn.
  • eagerness tá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-Rules HTTP 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 đó (vd utm_*, ?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ề prefetch thườ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

  1. Static asset có fingerprint chưa?immutable, max-age=31536000.
  2. HTML shellno-cache (không phải no-store).
  3. API public → SWR pattern + ETag.
  4. API privateprivate, max-age=0 + ETag.
  5. CDN có strip tracking params không? Nếu chưa, hit ratio đang thấp hơn nó có thể.
  6. bfcache có active không? Test bằng e.persisted.
  7. SW có chiến lược version + cleanup? Hay đang fill disk vô tận?
  8. Có monitor cache hit ratio chưa? < 70% là red flag.
  9. Trang có session không bị public? Audit lần cuối.
  10. Image / font có ở cookieless domain? Mỗi cookie là 4KB lãng phí.
  11. Đã nạp sẵn cache cho navigation kế tiếp chưa? Speculation Rules (prefetch/prerender) + 103 Early Hints cho trang quan trọng.
  12. 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.
  13. Data cá nhân hóa có lọt public không? Endpoint auth phải private/no-store, canh max-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:


Reference đầy đủ — HTTP Caching cheat sheet (bấm để mở)

Mục Lục

  1. Tổng quan kiến trúc
  2. Vòng đời của một request
  3. Cache-Control — Từng Directive Chi Tiết
  4. Các header liên quan: ETag, Last-Modified, Vary, Age
  5. Cơ chế Validation (304 Not Modified)
  6. Các tầng cache
  7. Chiến lược caching theo loại resource
  8. Auth & Caching Issues — Chi Tiết
  9. Các lỗi caching phổ biến
  10. 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 server

Lư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 response

Header 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=600
Browser → 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ày

Quan 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ới

Khi 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ó body

Phâ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ôn

Khi 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 response

Khá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, immutable

Tạ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ôn

Chỉ 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 fresh

3.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, blocking

Use 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 trang

Kế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-transform

Dù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-ModifiedIf-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ới

ETag 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êng

Pitfall: 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 + 2400s

Nế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 GMT

Vấn đề: Server và client clock có thể lệch nhau → tính toán sai.

Rule: Nếu cả Cache-ControlExpires 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 download

6. 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-Control headers
  • 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 cache

6.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ôn

CDN 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 fresh

8. 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 login

Fix 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 fresh

8.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 → Confusion

Fix: 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 expire

Pattern 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 database

Fix: 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ày

Hoặ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 data

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ết

Fix:

// 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 1

Fix: Đả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 → Incompatibility

Fix:

// 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, Age

Kiể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-store

Nguyên tắc cốt lõi:

  1. HTML luôn no-cache → Đảm bảo deploy mới được nhận
  2. Static assets dùng content hash → Cache vĩnh viễn an toàn
  3. User data dùng private → CDN không được cache
  4. Sensitive data dùng no-store → Không bao giờ lưu đĩa
  5. Dùng ETag + no-cache → Revalidate nhưng tiết kiệm bandwidth với 304
  6. Dùng Vary: Cookie → Mỗi user session một cache entry
  7. Xóa cache khi logout → Bảo mật trên shared device
  8. Align max-age với token lifetime → Không serve stale auth data