# 📨 HTTP cache headers: Cache-Control, ETag, Vary

By Better Wise

## Header cache: để trình duyệt tự quyết định thay server

Bài trước bạn đã chọn TTL cho cache nội bộ (Redis, cache-aside): dữ liệu này cũ bao lâu thì chấp nhận được. Bài này mang đúng câu hỏi đó ra khỏi server của bạn: trình duyệt, proxy và CDN được phép giữ response bao lâu, và làm sao chúng biết bản đang giữ còn đúng. Nhắc lại một dòng: TTL là hạn dùng của một bản copy, hết hạn thì phải xác minh lại hoặc tải lại.

Sau bài này bạn sẽ:

- Viết được `Cache-Control` với `max-age`, `s-maxage`, `private`/`public`, `no-store`.
- Phân biệt rõ `no-cache` và `no-store`.
- Giải thích luồng `ETag` / `Last-Modified` với `304 Not Modified`.
- Dùng `Vary` để cache không trả nhầm phiên bản.
- Chỉ ra được thời điểm trình duyệt trả lời mà không gọi server.

### Khi nào trình duyệt trả lời từ cache

Trước khi đọc từng header, hãy nhìn luồng quyết định. Mọi directive bên dưới chỉ là cách bạn chỉnh các nhánh rẽ trong sơ đồ này.

```mermaid
flowchart TD
    Req["🌐<br/>Request tới URL"] --> Q1{"Có bản trong cache?"}
    Q1 -- "Không" --> Net["Gọi server"]
    Q1 -- "Có" --> Q2{"Còn hạn max-age?"}
    Q2 -- "Còn" --> Fresh["✅<br/>Dùng luôn"]
    Q2 -- "Hết hạn" --> Q3{"Có ETag?"}
    Q3 -- "Có" --> Reval["Gửi If-None-Match"]
    Q3 -- "Không" --> Net
    Reval --> R{"Server trả gì?"}
    R -- "304" --> Reuse["♻️<br/>Dùng bản cũ"]
    R -- "200" --> New["📥<br/>Nhận bản mới"]
    Net --> New
```

Hai nhánh quan trọng nhất: **còn hạn** (không gọi server, nhanh nhất) và **hết hạn nhưng còn ETag** (gọi server nhưng gần như không tải dữ liệu).

### Cache-Control: bảng điều khiển chính

`Cache-Control` là nơi bạn đặt hạn dùng giống như hạn in trên hộp sữa trong tủ lạnh. Hết hạn không có nghĩa là sữa hỏng, chỉ là phải ngửi lại trước khi uống (revalidate).

| Directive | Ý nghĩa | Ai nghe |
| --- | --- | --- |
| `max-age=600` | Response tươi trong 600 giây | Mọi cache, gồm trình duyệt |
| `s-maxage=86400` | TTL riêng cho shared cache | CDN, proxy |
| `public` | Shared cache được phép lưu | CDN, proxy |
| `private` | Chỉ cache riêng của người dùng được lưu | Trình duyệt |
| `no-store` | Cấm lưu ở mọi nơi | Tất cả |
| `no-cache` | Được lưu nhưng phải revalidate trước khi dùng | Tất cả |
| `must-revalidate` | Hết hạn thì cấm dùng bản stale | Tất cả |
| `stale-while-revalidate=30` | Trả bản cũ tối đa 30 giây, làm mới ngầm | Tất cả |

Cái bẫy kinh điển: **no-cache không có nghĩa là không lưu**. Nó chỉ có nghĩa là "lưu thì lưu, nhưng lần nào dùng cũng phải hỏi lại server". Header cấm lưu thật sự là `no-store`.

Ví dụ một response sản phẩm:

```
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=30
```

Đọc thành lời: trình duyệt giữ 60 giây (_____ là hạn tươi, không gọi server), CDN giữ 300 giây, và trong 30 giây sau khi hết hạn thì được trả bản cũ kèm việc làm mới ngầm. `s-maxage` chỉ áp dụng cho _____, không áp dụng cho trình duyệt.

### ETag và Last-Modified: xác minh rẻ hơn tải lại

Sau khi hết `max-age`, trình duyệt chưa chắc phải tải lại toàn bộ. Nếu response có `ETag`, nó gửi kèm giá trị đó trong `If-None-Match`.

```
GET /api/products/42 HTTP/1.1
If-None-Match: "a1b2c3"
```

Server so với bản hiện tại. Chưa đổi thì trả:

```
HTTP/1.1 304 Not Modified
ETag: "a1b2c3"
```

Mã 304 không có body. Bạn tiết kiệm gần như toàn bộ băng thông mà client vẫn chắc dữ liệu đúng. `Last-Modified` là người anh em cũ hơn, hoạt động theo cặp với `If-Modified-Since` và chỉ chính xác tới mức giây, nên `ETag` được ưu tiên khi có thể.

ETag yếu viết là `W/"a1b2c3"`: chấp nhận hai bản khác byte nhưng tương đương về ngữ nghĩa.

### Vary: cùng URL nhưng khác phiên bản

Một URL không phải lúc nào cũng cho ra một response. Nếu response phụ thuộc vào request header nào, bạn phải khai báo header đó trong `Vary`, nếu không cache sẽ trả nhầm.

```
Vary: Accept-Encoding, Accept-Language
```

Thiếu `Accept-Encoding` mà bạn bật nén: client không hỗ trợ gzip có thể nhận file nén và hỏng. Thiếu `Accept-Language`: người dùng tiếng Việt nhận trang tiếng Anh. Header `Vary` cho cache biết response phụ thuộc vào _____ nào.

Với dữ liệu cá nhân, đừng chỉ dựa vào `Vary`. Response của `/api/me` phụ thuộc `Authorization` hay cookie thì nên chặn shared cache hẳn:

```
Cache-Control: private, no-cache
ETag: "user42-v7"
```

Ở đây `private` cấm CDN lưu, `no-cache` buộc trình duyệt hỏi lại mỗi lần, và ETag biến lần hỏi đó thành 304 rẻ. Lưu ý `Vary: *` gần như đồng nghĩa với không cache được.

### Ghép lại: ba response mẫu

**Trang chi tiết sản phẩm (đổi giá, nhưng không cần tức thời):**

```
Cache-Control: public, max-age=60, stale-while-revalidate=30
ETag: "prod-42-v9"
Vary: Accept-Encoding
```

60 giây là mức "cũ chấp nhận được" cho giá. Không ai phải chờ revalidate vì có `stale-while-revalidate`.

**File JS có hash trong tên:** `public, max-age=31536000, immutable`. Nội dung đổi thì tên file đổi, nên cache dài hạn là an toàn. Phần này bài sau mở rộng.

**Webhook hay dữ liệu tài chính:** `no-store`. Không có lý do gì để bất kỳ cache nào giữ lại.

### Năm lỗi header hay gặp trên production

- Nhầm `no-cache` với `no-store`, rồi tưởng đã cấm lưu trong khi CDN vẫn giữ.
- Đặt `max-age` vài giờ cho HTML, hậu quả là user thấy giá cũ sau khi deploy.
- Bật nén nhưng quên `Vary: Accept-Encoding`.
- Quên `private` cho response cá nhân, CDN cache dữ liệu người này rồi trả cho người khác.
- Sinh ETag từ thời điểm build thay vì từ nội dung, khiến 304 không bao giờ xảy ra.

Quy tắc của tôi: mỗi response, tự hỏi ba câu. Cái này cũ bao lâu thì chấp nhận được? Ai được phép giữ? Nó phụ thuộc header nào?

### Chốt lại và bài tiếp theo

- `Cache-Control` quyết định hạn tươi, phạm vi (public/private) và cách xử lý khi hết hạn.
- ETag biến lần hết hạn thành một lần xác minh rẻ với `304`, không có body.
- `Vary` là bắt buộc khi response thay đổi theo request header, còn dữ liệu cá nhân thì đi kèm `private`.

Bài tiếp theo đi sâu vào CDN cho static assets: vì sao file có hash trong tên cho phép bạn đặt `max-age` cả năm, và cache busting hoạt động ra sao khi bạn không đổi được tên file.

Front: Cache-Control: max-age=600 nghĩa là gì?
Back: Response được coi là tươi trong 600 giây kể từ lúc nhận, không cần gọi server.

Front: s-maxage khác max-age ở đâu?
Back: s-maxage chỉ áp cho shared cache như CDN/proxy, max-age áp cho mọi cache gồm trình duyệt.

Front: no-store khác no-cache thế nào?
Back: no-store cấm lưu ở mọi nơi, no-cache vẫn lưu nhưng phải revalidate trước mỗi lần dùng.

Front: private trong Cache-Control có tác dụng gì?
Back: Cấm shared cache (CDN) lưu, chỉ cache riêng của người dùng được giữ.

Front: ETag dùng để làm gì?
Back: Đánh dấu phiên bản nội dung để client hỏi lại bằng If-None-Match và nhận 304 nếu chưa đổi.

Front: Response 304 khác 200 ở điểm nào?
Back: 304 không có body, chỉ báo bản client đang giữ vẫn dùng được.

Front: Last-Modified đi cặp với request header nào?
Back: If-Modified-Since.

Front: Vary: Accept-Encoding để làm gì?
Back: Buộc cache phân biệt bản gzip và bản không nén của cùng một URL.

Front: Thiếu Vary cho response phụ thuộc Accept-Language thì sao?
Back: Cache có thể trả bản tiếng Anh cho người dùng tiếng Việt.

Front: stale-while-revalidate=30 cho phép gì?
Back: Trả bản cũ trong tối đa 30 giây và làm mới ngầm ở background.

Front: Vary: * gần như có nghĩa gì?
Back: Gần như không cache được, vì mọi request header đều có thể tạo phiên bản khác.

Front: ETag yếu được viết thế nào?
Back: W/"a1b2c3", dùng khi hai bản khác byte nhưng tương đương về ngữ nghĩa.

Front: Response cá nhân như /api/me nên đặt header gì?
Back: Cache-Control: private, no-cache kèm ETag để vẫn nhận được 304.

Front: must-revalidate có tác dụng gì?
Back: Cấm dùng bản stale khi đã hết hạn, buộc phải revalidate với origin.

---

## Contents

Part of [🗄️ Caching căn bản: Redis, TTL và CDN cho người mới](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi.md)

- **🧱 Nền tảng cache: vì sao và khi nào**
  - [💡 Cache là gì và khi nào nên dùng](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/cache-la-gi-va-khi-nao-nen-dung.md)
  - [🔁 Cache-aside: luồng đọc ghi kèm ví dụ](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/cache-aside-luong-doc-ghi-voi-vi-du.md) (sign-in required)
  - [✅ Checkpoint: Nền tảng cache](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/checkpoint-nen-tang-cache.md) (sign-in required)
- **⏱️ TTL, invalidation và cache phía HTTP**
  - [🗑️ TTL và invalidation: chọn thời điểm xóa](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/ttl-va-invalidation-chon-thoi-diem-xoa.md)
  - [📨 HTTP cache headers: Cache-Control, ETag, Vary](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/http-cache-headers-cache-control-etag-vary.md) — this lesson
  - [🌐 CDN cho static assets và cache busting](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/cdn-cho-static-assets-va-cache-busting.md)
  - [✅ Checkpoint: TTL và cache HTTP](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/checkpoint-ttl-va-cache-http.md)
- **⚠️ Redis và các lỗi cache thường gặp**
  - [🧰 Redis nhập môn: GET, SET, TTL và key naming](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/redis-nhap-mon-get-set-ttl-va-key-naming.md)
  - [🚨 Stale data và cache stampede](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/stale-data-va-cache-stampede.md)
  - [🤖 Yêu cầu AI thêm cache mà không phá logic](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/yeu-cau-ai-them-cache-khong-pha-logic.md) (premium content)
  - [✅ Checkpoint: Redis và lỗi cache](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/checkpoint-redis-va-loi-cache.md) (premium content)
- [Course cheatsheet](https://everwise.study/courses/caching-can-ban-redis-ttl-va-cdn-cho-nguoi-moi/cheatsheet.md) (premium content)
