Deploy xong nhưng user vẫn thấy giao diện cũ — browser cache file CSS/JS từ bản build trước, CDN chưa purge, HTML vẫn reference bundle cũ. Code mới nằm trên origin server nhưng cache ở mọi tầng đều giữ bản cũ. Gần như mọi team web đều gặp ít nhất một lần.

HTTP caching giúp web nhanh đáng kể — giảm latency, bandwidth, tải origin. Nhưng cache sai cách thì user thấy nội dung cũ, data nhạy cảm leak qua shared cache, hoặc deploy xong không ai nhận bản mới. Hiểu đúng cách HTTP caching hoạt động từ browser đến CDN là kỹ năng thiết yếu cho engineer làm web.


Các tầng cache trên đường đi của request

Khi browser gửi request, response có thể được cache ở nhiều tầng trước khi đến origin server, và mỗi tầng có hành vi riêng.

Tầng gần user nhất là browser cache (còn gọi là HTTP cache hoặc disk cache). Browser lưu response vào disk hoặc memory theo chỉ thị Cache-Control. Lần sau request cùng URL, browser kiểm tra cache trước — nếu còn hạn thì dùng luôn, không gửi request nào ra mạng. Đây là tầng cache hiệu quả nhất vì latency bằng 0 và không tốn bandwidth.

Tầng tiếp theo là CDN/proxy cache — các edge server đặt gần user về mặt địa lý. Cloudflare, Fastly, CloudFront, Akamai đều hoạt động ở tầng này. CDN cache response từ origin, serve cho nhiều user cùng region. Một response được cache ở edge Tokyo phục vụ hàng nghìn user Nhật Bản mà origin ở US East không phải xử lý gì. CDN tuân theo header Cache-Control nhưng có thể có thêm logic riêng (ví dụ Cloudflare có cache level setting riêng ngoài header).

Tầng cuối là origin server — nơi sinh response thực sự. Khi cả browser cache lẫn CDN cache đều miss hoặc hết hạn, request mới đến origin. Mục tiêu của caching strategy tốt là giảm tối đa số request đến origin mà vẫn đảm bảo user thấy nội dung đúng.

Ngoài ba tầng chính, còn có Service Worker cache nằm giữa browser cache và network — sẽ nói ở phần sau. Và trong môi trường enterprise, có thể có thêm reverse proxy (Varnish, Nginx proxy_cache) trước origin.


Cache-Control — header chi phối mọi thứ

Cache-Control là header quan trọng nhất trong HTTP caching. Nó xuất hiện trong response từ server và nói cho browser, CDN, và mọi proxy trung gian biết cách xử lý response đó.

max-age và s-maxage

max-age chỉ định số giây mà response được coi là “tươi” (fresh). Trong khoảng thời gian này, browser dùng bản cache mà không hỏi server.

Cache-Control: max-age=3600

Response trên được cache 1 giờ. Trong 1 giờ đó, mọi request đến cùng URL đều dùng bản cache — không có network request nào được gửi đi.

s-maxage (shared max-age) áp dụng riêng cho shared cache — CDN và proxy. Khi có cả max-ages-maxage, browser dùng max-age, CDN dùng s-maxage. Điều này cho phép chiến lược khác nhau giữa browser và CDN:

Cache-Control: max-age=60, s-maxage=86400

Browser cache 1 phút, CDN cache 24 giờ. User thấy bản mới sau tối đa 1 phút (browser revalidate), nhưng CDN giữ bản cache lâu hơn và chỉ hỏi origin 1 lần/ngày. Khi cần deploy gấp, purge CDN cache qua API — browser tự revalidate sau 1 phút.

public và private

public cho phép mọi tầng cache (browser, CDN, proxy) lưu response. private chỉ cho phép browser cache — CDN và shared proxy không được lưu.

Cache-Control: private, max-age=300

Dùng private cho response chứa dữ liệu cá nhân: trang profile, dashboard cá nhân, giỏ hàng. Nếu để public, CDN có thể cache response của user A và serve cho user B — data leak nghiêm trọng.

no-cache và no-store — hay bị nhầm

Hai directive này có tên dễ gây hiểu lầm. no-cache không có nghĩa là không cache — nó có nghĩa “cache được, nhưng phải revalidate với server trước mỗi lần dùng”. Browser vẫn lưu response vào disk, nhưng mỗi lần cần dùng sẽ gửi conditional request (If-None-Match hoặc If-Modified-Since) để kiểm tra response còn hợp lệ không. Nếu server trả 304 Not Modified thì browser dùng bản cache, tiết kiệm bandwidth vì không cần tải lại body.

Cache-Control: no-cache

no-store mới thực sự là “không cache gì cả”. Browser không lưu response vào disk hay memory. Mỗi request luôn đi đến server và nhận response đầy đủ. Dùng cho dữ liệu cực kỳ nhạy cảm: trang chứa token, thông tin tài chính, kết quả xác thực.

Cache-Control: no-store

Quy tắc đơn giản: no-cache = “hỏi lại server trước khi dùng” (vẫn tiết kiệm bandwidth nếu 304). no-store = “đừng nhớ gì cả” (tốn bandwidth mỗi lần). Phần lớn trường hợp HTML động cần no-cache, chỉ data nhạy cảm thật sự mới cần no-store.


ETag và Last-Modified — conditional request

Khi browser có bản cache nhưng cần kiểm tra với server (do no-cache hoặc max-age hết hạn), nó gửi conditional request thay vì request bình thường. Server so sánh và trả 304 Not Modified nếu nội dung chưa đổi — browser dùng bản cache, tiết kiệm bandwidth truyền body.

ETag

Server gắn ETag header vào response — thường là hash của nội dung hoặc version identifier:

HTTP/1.1 200 OK
ETag: "a1b2c3d4"
Cache-Control: no-cache
Content-Type: text/html

<html>...</html>

Lần sau browser gửi request kèm If-None-Match:

GET /page HTTP/1.1
If-None-Match: "a1b2c3d4"

Nếu ETag khớp (nội dung chưa đổi), server trả 304 không có body — response nhẹ vài trăm bytes thay vì vài chục KB HTML. Nếu nội dung đã đổi, server trả 200 với body mới và ETag mới.

Last-Modified

Tương tự ETag nhưng dùng timestamp thay vì hash:

HTTP/1.1 200 OK
Last-Modified: Wed, 29 Apr 2026 10:00:00 GMT
Cache-Control: max-age=60

Browser gửi If-Modified-Since khi revalidate:

GET /page HTTP/1.1
If-Modified-Since: Wed, 29 Apr 2026 10:00:00 GMT

ETag chính xác hơn Last-Modified vì dựa trên nội dung thực tế. Last-Modified chỉ chính xác đến giây — hai thay đổi trong cùng giây sẽ không phân biệt được. Hầu hết server hiện đại gửi cả hai, browser ưu tiên ETag khi có.


stale-while-revalidate và stale-if-error

Hai directive mở rộng này giải quyết bài toán thực tế: làm sao để user luôn thấy response nhanh ngay cả khi cache vừa hết hạn.

stale-while-revalidate

Cache-Control: max-age=60, stale-while-revalidate=300

Trong 60 giây đầu, response là fresh — dùng từ cache, không hỏi server. Sau 60 giây, response trở thành stale. Nhưng nhờ stale-while-revalidate=300, browser vẫn serve bản stale ngay lập tức cho user và revalidate với server ở background. Nếu server trả bản mới, cache được cập nhật — request tiếp theo nhận bản mới. User không bao giờ phải chờ revalidation.

Cửa sổ stale-while-revalidate kéo dài 300 giây (5 phút) sau khi max-age hết. Trong khoảng 60-360 giây, user nhận response cũ nhưng cache được refresh ngầm. Sau 360 giây mà chưa có ai request, cache bị coi là quá cũ — lần request tiếp theo phải chờ server response đầy đủ.

Pattern này cực kỳ hữu ích cho content thay đổi không thường xuyên nhưng cần load nhanh: trang chủ, danh sách sản phẩm, blog index. User luôn thấy response tức thì, nội dung mới nhất đến sau vài giây qua background revalidation.

stale-if-error

Cache-Control: max-age=60, stale-if-error=86400

Khi cache hết hạn và browser revalidate nhưng origin trả lỗi (5xx, network error), thay vì hiện trang lỗi cho user, browser serve bản cache cũ. stale-if-error=86400 cho phép dùng bản stale trong 24 giờ nếu origin không khả dụng.

Directive này là safety net cho availability. Origin sập 30 phút? User vẫn thấy nội dung (hơi cũ) thay vì trang trắng. CDN cũng hỗ trợ stale-if-error — Cloudflare gọi là “Always Online”, nhưng dùng header chuẩn thì portable hơn.


Vary header — cache theo điều kiện

Mặc định, cache key là URL. Nhưng cùng URL có thể trả response khác nhau tuỳ header request — ví dụ response nén gzip cho browser hỗ trợ, response không nén cho browser cũ. Vary header nói cho cache biết response phụ thuộc vào header nào:

Vary: Accept-Encoding

Câu trên nghĩa là cache cần lưu bản riêng cho mỗi giá trị Accept-Encoding khác nhau. Browser gửi Accept-Encoding: gzip, br nhận bản nén Brotli, browser gửi Accept-Encoding: gzip nhận bản gzip — hai bản cache riêng biệt cho cùng URL.

Vary: Accept-Language cache theo ngôn ngữ — hữu ích nếu server trả content negotiation theo header ngôn ngữ. Vary: Cookie cache theo cookie — hầu như vô dụng vì cookie thay đổi theo user, mỗi user một bản cache riêng, cache hit rate gần bằng 0.

Quy tắc quan trọng: Vary tăng số bản cache cho cùng URL. Mỗi header thêm vào Vary nhân số bản cache lên. Vary: Accept-Encoding, Accept-Language với 3 encoding × 5 ngôn ngữ = 15 bản cache cho một URL. CDN có giới hạn storage, Vary quá rộng giảm cache hit rate nghiêm trọng. Chỉ Vary theo header thực sự ảnh hưởng đến response content.


Cache busting — đảm bảo user nhận bản mới

Cache dài hạn tốt cho performance nhưng gây vấn đề khi deploy: user giữ bản cũ cho đến khi cache hết hạn. Cache busting là kỹ thuật buộc browser tải bản mới bằng cách thay đổi URL.

Hash trong filename

Cách đáng tin nhất: đặt content hash vào tên file. Build tool (Webpack, Vite, esbuild) tự sinh tên file dạng app.a1b2c3d4.js. Khi code thay đổi, hash thay đổi, URL mới — browser coi đây là resource hoàn toàn mới, tải về ngay.

<script src="/assets/app.a1b2c3d4.js"></script>

File có hash trong tên là immutable — nội dung không bao giờ thay đổi cho cùng URL. Cache cực dài hoàn toàn an toàn:

Cache-Control: public, max-age=31536000, immutable

max-age=31536000 là 1 năm — giá trị tối đa theo convention. immutable nói browser rằng file này không bao giờ thay đổi nên đừng revalidate khi user reload trang. Không có immutable, một số browser vẫn gửi conditional request khi user nhấn F5, tốn round-trip không cần thiết.

Query string — kém tin cậy hơn

<script src="/assets/app.js?v=a1b2c3d4"></script>

Cách này đơn giản hơn nhưng có vấn đề: một số CDN và proxy bỏ qua query string khi tạo cache key (mặc định Cloudflare include query string, nhưng cấu hình custom có thể khác). RFC cũng không mandate rằng URL khác query string phải được cache riêng. Hash trong filename đáng tin cậy và portable hơn — mình luôn khuyên dùng cách này.


Chiến lược khác nhau cho HTML và static assets

Đây là nguyên tắc cốt lõi mà nhiều team bỏ qua, dẫn đến tình huống mở đầu bài: deploy xong mà user thấy bản cũ.

Static assets (JS, CSS, ảnh, font)

File có hash trong tên — cache cực dài, immutable. Browser chỉ tải lại khi HTML reference URL mới (hash mới).

Cache-Control: public, max-age=31536000, immutable

HTML

HTML là file tham chiếu đến static assets. Nếu cache HTML lâu, browser dùng HTML cũ → reference asset cũ → user thấy bản cũ dù asset mới đã tồn tại trên server. HTML cần chiến lược cache ngắn hoặc revalidate:

Cache-Control: no-cache

Hoặc cache ngắn với stale-while-revalidate:

Cache-Control: max-age=60, stale-while-revalidate=300

Pattern kết hợp: HTML cache ngắn (hoặc no-cache) + static assets cache dài (immutable). Khi deploy, origin serve HTML mới reference asset mới. Browser nhận HTML mới (sau tối đa 60 giây hoặc ngay lập tức nếu no-cache), tải asset mới (URL mới vì hash khác), hiện bản mới. Asset cũ vẫn nằm trong cache nhưng không ai reference nữa — browser tự dọn khi cache đầy.


CDN caching — edge behavior và purge

CDN cache response ở edge server gần user. Hành vi cache của CDN tuân theo Cache-Control header nhưng có thêm đặc thù riêng.

Edge cache và origin shield

Khi request đến CDN edge mà cache miss, edge gọi về origin (hoặc qua origin shield — tầng cache trung gian giảm tải cho origin). Response được cache ở edge theo s-maxage (nếu có) hoặc max-age. Các request tiếp theo từ cùng region được serve từ edge — origin không bị tải.

CDN thường gửi header cho biết trạng thái cache trong response:

x-cache: HIT
age: 142
cf-cache-status: HIT

age cho biết response đã nằm trong cache bao lâu (giây). x-cache: HIT hoặc cf-cache-status: HIT (Cloudflare) xác nhận response từ cache, không phải từ origin. MISS nghĩa là cache miss, request đã đi đến origin.

Purge và invalidation

Khi deploy bản mới, cần xoá bản cache cũ ở CDN. Hầu hết CDN có purge API:

# Cloudflare
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
  -H "Authorization: Bearer {token}" \
  -d '{"files":["https://example.com/page"]}'

# Fastly
curl -X POST "https://api.fastly.com/service/{service_id}/purge/{surrogate_key}"

Purge theo URL cụ thể hoặc theo surrogate key (tag gắn vào nhóm response). Purge toàn bộ cache (purge_everything) thì nhanh nhưng gây cache miss đồng loạt — origin bị spike traffic. Purge có chọn lọc tốt hơn nhưng cần thiết kế surrogate key hợp lý.

Một số CDN hỗ trợ instant purge (Fastly) — cache bị xoá toàn cầu trong vài giây. Một số có delay vài phút. Kiểm tra SLA purge của CDN đang dùng — nếu purge mất 5 phút mà bạn cần user thấy bản mới ngay thì phải tính thêm cache busting ở HTML level.


API response caching

Không chỉ static assets — API response cũng có thể cache khi phù hợp. Endpoint trả dữ liệu ít thay đổi (danh mục sản phẩm, config public, exchange rate cập nhật mỗi giờ) hoàn toàn cache được:

Cache-Control: public, max-age=300, stale-while-revalidate=60

API trả dữ liệu cá nhân thì dùng private:

Cache-Control: private, max-age=60

API mutation (POST, PUT, DELETE) không nên cache — mặc định browser không cache non-GET request, nhưng nên explicit Cache-Control: no-store cho rõ ràng.

Lưu ý quan trọng: nếu API response có header Set-Cookie, hầu hết CDN sẽ không cache response đó (hoặc không nên cache). Response chứa Set-Cookie là per-user — cache ở shared layer sẽ set cookie của user A cho user B. Nếu API vừa trả data vừa set cookie, tách thành hai endpoint: endpoint data (cacheable) và endpoint auth (không cache).


Service Worker cache

Service Worker nằm giữa browser và network, cho phép kiểm soát cache ở mức programmatic thông qua Cache API. Đây là tầng cache mạnh nhất về mặt flexibility nhưng cũng phức tạp nhất.

Pattern phổ biến nhất là stale-while-revalidate implement trong Service Worker:

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.open("v1").then((cache) =>
      cache.match(event.request).then((cached) => {
        const fetched = fetch(event.request).then((response) => {
          cache.put(event.request, response.clone());
          return response;
        });
        return cached || fetched;
      })
    )
  );
});

Service Worker cache tồn tại qua page reload và thậm chí khi offline. Đây là nền tảng cho PWA (Progressive Web App) — app vẫn hoạt động khi mất mạng nhờ cache trong Service Worker.

Tuy nhiên, Service Worker cache cũng là nguồn gây stale content phổ biến. Nếu Service Worker cache HTML mà không có chiến lược update, user có thể mắc kẹt ở bản cũ vô thời hạn. Phải có cơ chế version Service Worker và skip waiting khi có bản mới — nếu không, user phải đóng tất cả tab rồi mở lại mới nhận bản Service Worker mới.


Sai lầm phổ biến

Cache response có authentication

Response chứa dữ liệu cá nhân mà thiếu Cache-Control: private hoặc no-store có thể bị CDN cache và serve cho user khác. Đây là lỗi bảo mật nghiêm trọng — user A thấy dashboard của user B. Quy tắc: mọi response sau authentication phải có private hoặc no-store. Không có ngoại lệ.

CDN cache response chứa Set-Cookie → user B nhận cookie session của user A → session hijacking. Hầu hết CDN hiện đại tự động bypass cache khi thấy Set-Cookie, nhưng không nên dựa vào hành vi implicit. Tách endpoint auth và endpoint data rõ ràng, hoặc strip Set-Cookie ở CDN level cho response cần cache.

max-age quá dài cho HTML

Cache-Control: max-age=86400 cho HTML nghĩa là user có thể thấy bản cũ tới 24 giờ sau deploy. Không có cách nào buộc browser xoá cache từ server side — browser tự quyết định dựa trên header đã nhận. Với HTML, no-cache hoặc max-age ngắn (vài phút) là an toàn. Dùng max-age dài chỉ cho static assets có hash trong filename.

Vary: * hoặc Vary quá rộng

Vary: * disable cache hoàn toàn vì mọi request được coi là unique. Vary: Cookie gần như disable shared cache vì mỗi user có cookie khác nhau. Chỉ Vary theo header thực sự ảnh hưởng response — thường là Accept-Encoding và đôi khi Accept-Language.

Thiếu cache cho static assets

Không set Cache-Control cho JS, CSS, font, ảnh nghĩa là browser dùng heuristic caching — tự đoán cache bao lâu dựa trên Last-Modified header. Heuristic khác nhau giữa browser, kết quả không đoán trước được. Luôn set explicit Cache-Control cho mọi response.


Debug cache

Khi cache không hoạt động như mong đợi, cần công cụ kiểm tra từng tầng.

Browser DevTools

Mở tab Network, chọn request, xem cột “Size” — nếu ghi “(disk cache)” hoặc “(memory cache)” thì response từ browser cache, không có network request. Cột “Status” ghi 304 nghĩa là conditional request thành công, browser dùng bản cache. Response headers tab hiển thị Cache-Control, ETag, Age từ server hoặc CDN.

Checkbox “Disable cache” trong DevTools tắt browser cache cho tab đang mở — hữu ích khi debug nhưng nhớ rằng user thật không bật option này. Test cache behavior phải test với cache bật.

CDN headers

Hầu hết CDN gắn header cho biết trạng thái cache:

cf-cache-status: HIT          # Cloudflare
x-cache: Hit from cloudfront   # CloudFront
x-fastly-request-id: ...       # Fastly
age: 3542                      # Bao lâu response nằm trong cache

HIT = từ cache. MISS = từ origin. EXPIRED = cache hết hạn, đã revalidate. BYPASS = CDN skip cache (thường do rule hoặc cookie). DYNAMIC (Cloudflare) = CDN không cache response type này.

Dùng curl -I để kiểm tra header mà không tải body:

curl -I https://example.com/assets/app.a1b2c3.js

Kiểm tra Cache-Control, Age, ETag, và CDN-specific header. So sánh giá trị thực tế với giá trị mong đợi — nếu lệch thì có misconfiguration ở server hoặc CDN rule.

Cache key inspection

Khi CDN cache miss bất thường, kiểm tra cache key. CDN tạo cache key từ URL + Vary headers + đôi khi query string. Nếu URL có parameter tracking (?utm_source=...), mỗi tổ hợp parameter tạo cache entry riêng — cache hit rate giảm đáng kể. Cấu hình CDN strip query parameter không ảnh hưởng content (UTM params) ra khỏi cache key.


Tóm tắt

HTTP caching hoạt động theo tầng: browser cache → CDN/proxy → origin. Mỗi tầng tuân theo Cache-Control header mà origin gửi về, nên thiết kế header đúng là thiết kế hành vi cache toàn tuyến. max-ages-maxage kiểm soát thời gian cache ở browser và CDN riêng biệt. no-cache cho phép cache nhưng bắt revalidate mỗi lần — khác hoàn toàn với no-store cấm cache.

Static assets có hash trong filename cache immutable (1 năm, immutable). HTML cache ngắn hoặc no-cache — đây là file tham chiếu đến asset, phải cập nhật nhanh khi deploy. Pattern này đảm bảo user nhận bản mới trong vài phút mà static assets vẫn được cache tối đa.

ETag và conditional request tiết kiệm bandwidth khi revalidate. stale-while-revalidate cho response tức thì ngay cả khi cache vừa hết hạn. Vary header cache theo điều kiện nhưng phải dùng cẩn thận — Vary quá rộng giết cache hit rate.

Response sau authentication phải private hoặc no-store — không bao giờ để CDN cache dữ liệu cá nhân. Response có Set-Cookie không nên nằm trong shared cache. Debug bằng DevTools Network tab và CDN-specific header (cf-cache-status, x-cache, age) để xác nhận hành vi cache đúng như thiết kế.