BlogBlog · http

HTTP caching từ A tới Z: Cache-Control, ETag, CDN và stale-while-revalidate cho web app

Nghĩa chính xác của từng directive Cache-Control, luồng ETag và 304, header nên dùng cho asset, HTML, API và dữ liệu theo user, Vary và purge theo tag trên CDN, header mặc định của Next.js, cách debug bằng curl và cách không bao giờ cache nhầm dữ liệu đăng nhập.

Part of Deep Dive series
July 17, 2025
24 min read
By Leo Pham
httpcachingcdnperformancenextjs
Share this post:

Hai sự cố caching mình từng phải xử lý có cùng một gốc. Lần thứ nhất, team deploy bản sửa lỗi thanh toán nhưng một phần user vẫn chạy JavaScript cũ suốt ba ngày, vì file checkout.js (không có hash trong tên) được trả về với max-age=604800. Lần thứ hai nguy hiểm hơn: ai đó thêm s-maxage=60 cho mọi route "để trang nhanh hơn", và trong vài phút, người dùng mở trang tài khoản thấy tên và lịch sử đơn hàng của người khác.

Cả hai đều đến từ việc đoán nghĩa của header thay vì biết chính xác. Bài này đi qua từng tầng cache, nghĩa đúng theo RFC 9111 của từng directive Cache-Control, luồng ETag và 304, bảng header nên dùng cho từng loại resource, cách CDN xây cache key và purge theo tag, Next.js đặt header gì mặc định, và cách debug bằng curl cho tới khi bạn chắc chắn response nào đang được cache ở đâu.

TL;DR:

  • no-cache không có nghĩa "không cache"; nó nghĩa là "được lưu, nhưng phải hỏi lại origin trước mỗi lần dùng". Không lưu gì cả là no-store.
  • Asset có hash trong tên: public, max-age=31536000, immutable. HTML: browser luôn revalidate, CDN cache ngắn kèm stale-while-revalidate.
  • Response phụ thuộc user (cookie, token) không bao giờ được có public hoặc s-maxage. CDN không đưa cookie vào cache key.
  • Vary: Cookie không phải cách an toàn để cache theo user: có CDN từ chối cache hẳn, có CDN bỏ qua Vary.
  • Debug bằng curl -D -, nhìn Age, x-vercel-cache hoặc cf-cache-status, gửi request vài lần và từ đúng region.

Ba tầng cache và ai đọc header nào

Browser ──► CDN edge (PoP gần user) ──► Origin (Next.js, API) ──► App cache (Redis, Data Cache) ──► Database
private cache        shared cache                                   application cache
Tầng Ví dụ Điều khiển bằng Phạm vi Xóa thế nào
Private cache Cache của trình duyệt Cache-Control, ETag, Last-Modified Một user, một thiết bị Không xóa được từ server; chỉ đổi URL hoặc đợi hết hạn
Shared cache Vercel CDN, Cloudflare, Fastly, CloudFront, nginx/Varnish Cache-Control (s-maxage, public), CDN-Cache-Control, header riêng của CDN Mọi user đi qua cùng PoP/region Purge theo URL hoặc theo tag
Application cache Redis, Next.js Data Cache, 'use cache', Vercel Runtime Cache Code của bạn Tùy bạn thiết kế Code của bạn (TTL, revalidateTag, xóa key)

Điểm cần nhớ: hai tầng đầu đọc header HTTP và hành xử theo RFC. Tầng thứ ba không liên quan gì đến header: một response no-store vẫn có thể được render từ dữ liệu lấy trong Redis. Khi user báo "dữ liệu cũ", câu hỏi đầu tiên luôn là "cũ ở tầng nào".

Tầng private quan trọng hơn vẻ ngoài vì nó không purge được. Một response đã nằm trong cache trình duyệt với max-age=604800 sẽ nằm đó một tuần bất kể bạn deploy bao nhiêu lần. Đó là lý do toàn bộ chiến lược cache cho asset dựa trên việc đổi URL, không dựa trên việc xóa.

Cache-Control: nghĩa chính xác của từng directive

# Asset có hash: cache một năm, không bao giờ hỏi lại
Cache-Control: public, max-age=31536000, immutable

# HTML công khai: browser luôn hỏi lại, CDN giữ 5 phút, phục vụ bản cũ tối đa 1 ngày khi đang làm mới
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400

# API theo user: chỉ browser lưu, phải hỏi lại trước mỗi lần dùng
Cache-Control: private, no-cache

# Dữ liệu nhạy cảm: không ai được lưu
Cache-Control: no-store

Từng directive trong response:

  • max-age=N: response "fresh" trong N giây kể từ lúc origin tạo ra nó. Áp dụng cho mọi cache, trừ khi shared cache có s-maxage riêng. Thời gian đã trôi qua được tính cả Age mà các cache trung gian cộng vào: max-age=600 đi kèm Age: 540 nghĩa là chỉ còn 60 giây fresh.
  • s-maxage=N: chỉ shared cache (CDN, proxy) đọc, và nó ghi đè max-age với các cache đó. Theo RFC 9111, s-maxage còn mang nghĩa của proxy-revalidate: khi đã stale, shared cache không được tự ý phục vụ bản cũ nếu không có directive cho phép như stale-while-revalidate.
  • no-cache: cache được phép lưu, nhưng trước mỗi lần dùng phải revalidate với origin. Kết hợp với ETag, kết quả thường là một request nhỏ trả 304 thay vì tải lại toàn bộ body.
  • no-store: không cache nào được lưu response, kể cả trình duyệt. Dùng cho token, dữ liệu cá nhân, trang ngân hàng.
  • private: chỉ private cache (trình duyệt) được lưu; shared cache không được lưu.
  • public: mọi cache được lưu, kể cả trong những trường hợp mặc định không được, ví dụ response cho request có header Authorization. Chính vì vậy public trên một API dùng token là cực kỳ nguy hiểm.
  • must-revalidate: khi đã stale thì không được phục vụ bản cũ trong bất kỳ hoàn cảnh nào (kể cả origin lỗi) nếu chưa revalidate thành công.
  • immutable (RFC 8246): nội dung không đổi trong thời gian fresh, nên trình duyệt không cần revalidate kể cả khi user bấm reload. Chỉ dùng cho URL có hash.
  • stale-while-revalidate=N (RFC 5861): sau khi hết fresh, cache được phục vụ bản cũ thêm tối đa N giây trong lúc tự làm mới ở background. User không phải chờ origin. Hỗ trợ ở các CDN lớn tốt hơn nhiều so với ở trình duyệt, nên mình chỉ dựa vào nó ở tầng CDN.
  • stale-if-error=N (RFC 5861): khi origin trả 5xx hoặc không kết nối được, cache được phục vụ bản cũ tối đa N giây. Đây là lớp bảo hiểm rẻ nhất cho các trang công khai.

Khi response không có Cache-Control lẫn Expires nhưng có Last-Modified, cache được phép tự ước lượng thời gian fresh (heuristic freshness; RFC gợi ý khoảng 10% thời gian từ Last-Modified đến hiện tại). Một file sửa lần cuối cách đây 30 ngày có thể được cache khoảng 3 ngày mà bạn không hề yêu cầu. Bài học: luôn đặt Cache-Control tường minh, kể cả khi giá trị là no-cache.

Expires và Pragma: no-cache là header cũ. Khi có max-age, cache bỏ qua Expires. Đừng dựa vào Pragma để chặn cache trong response.

Validators: ETag, Last-Modified và luồng 304

Khi một response đã stale (hoặc có no-cache), cache không vứt nó đi mà gửi một conditional request:

  1. Response gốc có ETag: "a1b2c3" (hoặc Last-Modified).
  2. Lần sau, cache gửi kèm If-None-Match: "a1b2c3" (hoặc If-Modified-Since).
  3. Nếu nội dung không đổi, origin trả 304 Not Modified không có body. Cache làm mới thời gian fresh và dùng lại body cũ.
  4. Nếu đổi, origin trả 200 với body mới và ETag mới.

Kiểm chứng bằng curl (output minh họa từ một origin tự host):

# Xem header của request GET thật (curl -I gửi HEAD, không phải route nào cũng xử lý HEAD như GET)
curl -s -o /dev/null -D - "https://shop.example.com/api/products?category=laptop"

# Gửi lại với ETag vừa nhận
curl -s -o /dev/null -D - \
  -H 'If-None-Match: "6f1c2b9e0d"' \
  "https://shop.example.com/api/products?category=laptop"
HTTP/2 200
content-type: application/json
cache-control: public, max-age=0, s-maxage=60, stale-while-revalidate=300
etag: "6f1c2b9e0d"
content-length: 18342

HTTP/2 304
cache-control: public, max-age=0, s-maxage=60, stale-while-revalidate=300
etag: "6f1c2b9e0d"

Tự cài ETag trong một Next.js Route Handler:

// app/api/products/route.ts
import { createHash } from 'node:crypto'
import { getProducts } from '@/lib/catalog'

export async function GET(request: Request) {
  const category = new URL(request.url).searchParams.get('category') ?? 'all'
  const products = await getProducts(category)
  const body = JSON.stringify(products)
  const etag = `"${createHash('sha256').update(body).digest('base64url').slice(0, 16)}"`

  const headers = {
    'Cache-Control': 'public, max-age=0, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400',
    ETag: etag,
  }

  // If-None-Match dùng so sánh weak: bỏ tiền tố W/ trước khi so
  const ifNoneMatch = request.headers.get('if-none-match')
  const matches = ifNoneMatch
    ?.split(',')
    .map((tag) => tag.trim().replace(/^W\//, ''))
    .some((tag) => tag === etag || tag === '*')

  if (matches) return new Response(null, { status: 304, headers })
  return new Response(body, { status: 200, headers: { ...headers, 'Content-Type': 'application/json' } })
}

Cái giá thực sự của 304: nó tiết kiệm băng thông và thời gian parse, không tiết kiệm round trip. Và trong cách cài trên, server vẫn query database và serialize toàn bộ dữ liệu chỉ để tính hash. Với endpoint nặng, sinh ETag từ thứ rẻ hơn: một số version tăng dần mỗi khi catalog đổi, hoặc max(updated_at) kèm số dòng, rồi so sánh trước khi lấy dữ liệu đầy đủ.

Ba edge case hay gặp với ETag:

  • Weak vs strong: W/"..." là weak ETag, nghĩa là "tương đương về ngữ nghĩa". Một số server và proxy đổi ETag strong thành weak khi nén response on-the-fly; với If-None-Match thì vẫn khớp nhờ so sánh weak, nhưng với If-Range (tải tiếp file) thì không.
  • Nhiều instance: ETag sinh từ thông tin riêng của từng máy (inode, mtime khi build) sẽ khác nhau giữa các instance sau load balancer, và mọi revalidation đều thành 200. Sinh ETag từ nội dung.
  • Next.js tự sinh ETag cho page (option generateEtags, mặc định bật) và tự trả 304. Với Route Handler, bạn tự làm như trên.

Header khuyến nghị theo loại resource

Loại resource Cache-Control Lý do và lưu ý
Asset có hash (/_next/static/..., app.3f9a1c.js, font có hash) public, max-age=31536000, immutable Nội dung đổi thì URL đổi; không bao giờ cần revalidate
File tĩnh không hash (/favicon.ico, /images/logo.png) public, max-age=3600 hoặc public, no-cache kèm ETag Không purge được cache trình duyệt; đừng để TTL dài
Service worker (/sw.js) no-cache Bản mới phải được phát hiện ngay ở lần kiểm tra tiếp theo
HTML công khai, đổi theo phút/giờ Browser: max-age=0, must-revalidate; CDN: s-maxage=300, stale-while-revalidate=86400 HTML trỏ tới chunk của bản build hiện tại; giữ ở browser lâu là gặp lỗi tải chunk sau deploy
HTML có dữ liệu theo user private, no-cache hoặc private, no-store Không bao giờ public hay s-maxage
API JSON công khai (catalog, giá, bài viết) public, max-age=0, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400 + ETag CDN gánh tải, origin lỗi vẫn có dữ liệu
API JSON theo user (/api/me, giỏ hàng) private, no-cache + ETag Trình duyệt revalidate, CDN không lưu
Dữ liệu nhạy cảm (token, PII, giao dịch) no-store Không lưu ở đâu cả; lưu ý có thể ảnh hưởng tới back/forward cache ở một số trình duyệt
File tải về có kiểm soát quyền (hóa đơn PDF) private, no-store Hoặc dùng signed URL hết hạn ngắn trên object storage
Redirect 301 Đặt Cache-Control tường minh Trình duyệt có thể nhớ 301 rất lâu; dùng 307/308 hoặc 302 khi chưa chắc chắn

Hai dòng HTML là chỗ mọi người hay làm sai nhất. Bạn muốn CDN giữ HTML để đỡ tải cho origin, nhưng không muốn trình duyệt giữ, vì trình duyệt không purge được. Cách tách hai giá trị đó: max-age=0 cho trình duyệt và s-maxage cho CDN trong cùng một header, hoặc rõ ràng hơn là dùng CDN-Cache-Control (RFC 9213), header chỉ CDN đọc:

// Một Route Handler trả HTML hoặc JSON công khai
export async function GET() {
  const html = await renderLandingPage()
  return new Response(html, {
    headers: {
      'Content-Type': 'text/html; charset=utf-8',
      'Cache-Control': 'public, max-age=0, must-revalidate', // trình duyệt
      'CDN-Cache-Control': 'public, s-maxage=300, stale-while-revalidate=86400', // CDN
    },
  })
}

async function renderLandingPage() {
  return '<!doctype html><title>Landing</title><h1>Khuyến mãi tháng 11</h1>'
}

Vercel và Cloudflare đều hỗ trợ CDN-Cache-Control. Vercel còn có Vercel-CDN-Cache-Control, chỉ CDN của Vercel đọc và không bị gửi tiếp xuống trình duyệt hay CDN khác.

Cache key, Vary và purge theo tag

CDN tra cache bằng một cache key, mặc định gần như luôn gồm method, host, path và query string. Theo docs của Vercel, cache key của nó gồm method, URL (query string bị bỏ qua với file tĩnh), host, deployment URL và scheme. Hệ quả thực tế:

  • ?utm_source=facebook và ?utm_source=zalo là hai cache entry khác nhau cho cùng một trang. Chiến dịch marketing có thể kéo hit ratio về gần 0. Cloudflare cho phép tùy chỉnh cache key bằng Cache Rules để bỏ các tham số tracking; trên các CDN khác, chuẩn hóa URL ở tầng routing.
  • Cookie không nằm trong cache key mặc định. Hai user khác cookie cùng gọi /account là cùng một key.

Vary là cách nói với cache "thêm những request header này vào key". Vary: Accept-Encoding gần như luôn đúng và Vercel đã tự đưa Accept, Accept-Encoding vào key. Vary: Accept-Language hợp lý khi bạn render theo ngôn ngữ trình duyệt. Vary: Cookie thì không:

  • Vercel không cache response có Vary: Cookie (trả x-vercel-cache: MISS, log lý do "Vary key denied"), vì cookie gần như duy nhất cho từng visitor.
  • Không phải CDN nào cũng tôn trọng Vary đầy đủ. Nếu CDN của bạn bỏ qua Vary ngoài Accept-Encoding, response có Vary: Cookie sẽ được phục vụ chung cho mọi người. Kiểm tra docs CDN trước khi trông chờ Vary để tách dữ liệu.
  • Kể cả khi CDN tôn trọng, mỗi user là một entry, hit ratio xấp xỉ 0, bạn trả tiền cache mà không được gì.

Cách đúng để cá nhân hóa trang có thể cache: giữ HTML chung cho mọi người, phần theo user (tên, giỏ hàng) lấy bằng request riêng có private.

Khi nội dung thay đổi trước khi hết TTL, bạn cần purge. Purge theo URL không thực tế khi một sản phẩm xuất hiện trên trang chi tiết, trang danh mục, trang tìm kiếm và API. Cache tag (còn gọi là surrogate key) giải quyết chuyện đó: gắn tag cho response, rồi purge theo tag.

// app/api/products/[id]/route.ts — gắn tag
import { getProduct } from '@/lib/catalog'

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProduct(id)
  if (!product) return Response.json({ error: 'not_found' }, { status: 404 })

  return Response.json(product, {
    headers: {
      'Cache-Control': 'public, max-age=0, must-revalidate',
      'Vercel-CDN-Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=604800',
      'Vercel-Cache-Tag': `product-${id},products`,
    },
  })
}
// app/api/webhooks/cms/route.ts — CMS gọi khi sản phẩm thay đổi
import { timingSafeEqual } from 'node:crypto'
import { invalidateByTag } from '@vercel/functions'

function safeEqual(a: string, b: string) {
  const ab = Buffer.from(a)
  const bb = Buffer.from(b)
  return ab.length === bb.length && timingSafeEqual(ab, bb)
}

export async function POST(request: Request) {
  const secret = request.headers.get('x-webhook-secret') ?? ''
  if (!safeEqual(secret, process.env.CMS_WEBHOOK_SECRET ?? '')) {
    return new Response('Unauthorized', { status: 401 })
  }
  const { productId } = (await request.json()) as { productId: string }
  await invalidateByTag([`product-${productId}`, 'products'])
  return Response.json({ ok: true })
}

Vercel phân biệt hai kiểu purge. invalidateByTag đánh dấu entry là stale: request kế tiếp nhận bản cũ ngay lập tức trong khi CDN làm mới ở background. dangerouslyDeleteByTag xóa hẳn: request kế tiếp phải đợi origin, và nếu tag đó gắn với hàng nghìn URL đang có traffic, tất cả cùng đổ về origin một lúc (cache stampede). Mặc định dùng invalidate. Trên Next.js 16, revalidateTag(tag, 'max') cho hành vi invalidate, revalidateTag(tag) một tham số và updateTag cho hành vi xóa.

Các CDN khác có cơ chế tương đương: Cloudflare dùng header Cache-Tag (bị gỡ khỏi response trước khi tới visitor) và API purge theo tag; Fastly dùng Surrogate-Key. Tên khác nhau, ý tưởng giống nhau.

Next.js: header mặc định và cách tùy chỉnh

Next.js tự đặt Cache-Control cho phần lớn response. Biết chính xác giá trị giúp bạn không ghi đè nhầm:

Loại response Header Next.js đặt Ghi chú
/_next/static/* (JS, CSS có hash, ảnh import tĩnh) public, max-age=31536000, immutable Không ghi đè được trong next.config
Trang prerender tĩnh (không revalidate) s-maxage=31536000 CDN giữ đến khi deploy mới hoặc purge
Trang ISR với revalidate = 60 s-maxage=60, stale-while-revalidate=31535940 Phần SWR = expireTime (mặc định 1 năm) trừ revalidate
Trang dynamic (đọc cookies(), headers(), revalidate = 0) private, no-cache, no-store, max-age=0, must-revalidate An toàn mặc định cho dữ liệu theo user
Route Handler GET Không cache mặc định (từ Next.js 15) Bạn tự đặt header trong Response

Với ISR, stale-while-revalidate gần một năm nghĩa là CDN được phép phục vụ bản cũ rất lâu nếu không có request nào kích hoạt làm mới, hoặc nếu lần làm mới thất bại. Muốn giới hạn khoảng đó, đặt expireTime trong next.config: với revalidate 15 phút và expireTime: 3600, header thành s-maxage=900, stale-while-revalidate=2700.

Trên Vercel, CDN "tiêu thụ" s-maxage: khi bạn chỉ đặt Cache-Control mà không có CDN-Cache-Control, Vercel gỡ s-maxage và stale-while-revalidate trước khi gửi cho trình duyệt, và trình duyệt nhận public, max-age=0, must-revalidate. Đó là lý do bạn thấy header trên DevTools khác với header code đặt. Khi tự host sau một CDN khác, header gốc đi thẳng tới CDN đó, nên hãy đọc docs CDN để biết nó xử lý s-maxage và stale-while-revalidate thế nào.

Header cho file trong public/ và các path đặc biệt đặt bằng next.config:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  expireTime: 86400, // ISR: cho phép phục vụ bản stale tối đa 1 ngày tính từ lúc tạo
  async headers() {
    return [
      {
        // Font tự host có version trong path: /fonts/v3/inter.woff2
        source: '/fonts/:version/:file',
        headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
      },
      {
        source: '/sw.js',
        headers: [{ key: 'Cache-Control', value: 'no-cache' }],
      },
      {
        source: '/images/:path*',
        headers: [{ key: 'Cache-Control', value: 'public, max-age=3600, stale-while-revalidate=86400' }],
      },
    ]
  },
}

export default nextConfig

Không đặt Cache-Control cho page bằng source: '/:path*' trong next.config. Thời gian cache của page nên đến từ revalidate, generateStaticParams hoặc Cache Components ('use cache' với cacheLife), để Next.js tự chọn header đúng cho trang static, ISR và dynamic. Một rule wildcard sẽ áp cùng một header cho cả trang /account đọc cookie. Nếu bạn dùng Cache Components, cách các profile cacheLife chuyển thành header phụ thuộc vào version; kiểm tra bằng curl thay vì đoán. Mental model đầy đủ về các tầng cache của Next.js nằm ở bài Next.js App Router và React Server Components.

Debug: cache đang ở đâu, HIT hay MISS

Trong Chrome DevTools, tab Network:

  • Cột Size hiện (memory cache) hoặc (disk cache) nghĩa là trình duyệt không gửi request nào; status 304 nghĩa là đã revalidate với server và dùng lại body.
  • Checkbox Disable cache chỉ có tác dụng khi DevTools đang mở. Rất nhiều lần "cache không hoạt động" chỉ vì checkbox này đang bật.
  • Reload thường vẫn dùng cache cho subresource còn fresh; hard reload (Cmd/Ctrl + Shift + R) bỏ qua cache. Muốn kiểm tra trải nghiệm thật của user, mở trang bằng cách bấm link hoặc gõ URL, không phải bấm reload.

Ở tầng CDN, curl đáng tin hơn trình duyệt vì không có cache local và bạn thấy mọi header:

url="https://shop.example.com/api/products?category=laptop"
for i in 1 2 3; do
  curl -s -o /dev/null -D - "$url" \
    | grep -iE '^(cache-control|cdn-cache-control|age|etag|x-vercel-cache|cf-cache-status|x-nextjs-cache):'
  echo '---'
  sleep 2
done

Output minh họa trên Vercel:

cache-control: public, max-age=0, must-revalidate
etag: "6f1c2b9e0d"
x-vercel-cache: MISS
---
cache-control: public, max-age=0, must-revalidate
age: 2
etag: "6f1c2b9e0d"
x-vercel-cache: HIT
---
cache-control: public, max-age=0, must-revalidate
age: 4
etag: "6f1c2b9e0d"
x-vercel-cache: HIT

Age tăng dần giữa các lần gọi là bằng chứng bạn đang nhận cùng một bản được lưu. Bảng tra nhanh các header trạng thái:

Header Giá trị Nghĩa
x-vercel-cache HIT / MISS Phục vụ từ cache / không có trong cache, gọi function hoặc origin
STALE Phục vụ bản cũ, đang làm mới ở background
PRERENDER Phục vụ từ static storage (trang prerender)
REVALIDATED Làm mới ở foreground sau khi entry bị xóa
BYPASS Bỏ qua cache
cf-cache-status HIT / MISS / EXPIRED Từ cache / chưa có / đã hết hạn và lấy bản mới
UPDATING Phục vụ bản hết hạn trong khi làm mới ở background
STALE Phục vụ bản cũ vì không kết nối được origin
REVALIDATED Origin xác nhận bản lưu vẫn đúng qua conditional request
BYPASS / DYNAMIC Response không cache được / request không đủ điều kiện cache
x-nextjs-cache HIT / STALE / MISS / REVALIDATED Trạng thái ISR cache của Next.js khi tự host

Ba bẫy khi đọc kết quả:

  • Cache CDN được chia theo region hoặc PoP. HIT từ máy bạn ở Hà Nội không có nghĩa user ở Frankfurt cũng HIT. Request đầu tiên sau deploy ở mỗi region luôn là MISS.
  • Cloudflare mặc định không cache HTML và JSON, chỉ cache theo phần mở rộng file (ảnh, JS, CSS, font...). cf-cache-status: DYNAMIC trên trang HTML là hành vi mặc định, không phải lỗi; muốn cache HTML bạn phải tạo Cache Rule.
  • Trên Vercel, dữ liệu lấy từ Runtime Cache bên trong function vẫn hiện x-vercel-cache: MISS, vì header này chỉ nói về CDN cache.

Lỗi nguy hiểm nhất: CDN cache response của user đã đăng nhập

Kịch bản thật: một dev thêm rule này vào next.config để "tăng tốc toàn site":

// ĐỪNG LÀM: áp dụng cho mọi route, kể cả /account
async headers() {
  return [{ source: '/:path*', headers: [{ key: 'Cache-Control', value: 'public, s-maxage=60' }] }]
}

Trên một CDN không có lớp bảo vệ riêng, user A mở /account, HTML chứa tên, email và đơn hàng của A được lưu 60 giây. Mọi user khác đi qua cùng PoP trong 60 giây đó nhận trang của A. Không có gì báo lỗi: status 200, trang render đẹp, chỉ sai người.

Vì sao chuyện này xảy ra:

  1. CDN không đưa cookie vào cache key. Với nó, /account của A và của B là một URL.
  2. RFC 9111 có một lớp bảo vệ: shared cache không được lưu response cho request có header Authorization, trừ khi response có public, s-maxage hoặc must-revalidate. Nghĩa là public hay s-maxage chính là cách bạn tắt lớp bảo vệ đó. Với auth bằng cookie thì còn không có lớp bảo vệ này.
  3. Một số CDN có lớp an toàn riêng, ví dụ theo docs, Vercel không cache khi request có Authorization hoặc response có Set-Cookie, và Cloudflare không cache response có Set-Cookie. Nhưng một request chỉ mang cookie session, nhận về HTML không kèm Set-Cookie, vẫn hoàn toàn đủ điều kiện để được cache nếu header cho phép.
  4. Biến thể tệ hơn: một CDN chịu cache response có Set-Cookie, và cookie session của một user được phát cho người khác. Đó không còn là lộ dữ liệu, mà là chiếm phiên đăng nhập.

Cách phòng thủ theo nhiều lớp:

  • Mặc định từ chối. Route đọc session phải trả private, no-store (hoặc private, no-cache khi muốn trình duyệt revalidate). Trang dynamic của Next.js đã làm vậy mặc định; đừng ghi đè bằng rule wildcard.
  • Whitelist, không blacklist. Chỉ đặt s-maxage cho những path liệt kê rõ ràng (/, /blog/:slug, /api/products), không bao giờ cho /:path*.
  • Tách dữ liệu cá nhân khỏi response được cache. HTML chung cho mọi người, phần theo user lấy qua API private.
  • Cấu hình CDN bỏ qua cache khi có cookie session, như một lớp dự phòng chứ không phải lớp duy nhất.
  • Kiểm tra tự động trong CI sau mỗi deploy preview:
// scripts/check-private-cache.ts — chạy bằng: npx tsx scripts/check-private-cache.ts
const BASE_URL = process.env.CHECK_BASE_URL ?? 'http://localhost:3000'
const SESSION = process.env.CHECK_SESSION_TOKEN ?? ''
const PRIVATE_PATHS = ['/account', '/api/me', '/orders']

async function main() {
  let failed = false
  for (const path of PRIVATE_PATHS) {
    const res = await fetch(new URL(path, BASE_URL), {
      headers: { cookie: `session=${SESSION}` },
      redirect: 'manual',
    })
    const cacheControl = (res.headers.get('cache-control') ?? '').toLowerCase()
    const cdnCacheControl = (res.headers.get('cdn-cache-control') ?? '').toLowerCase()

    const sharedCacheable =
      /\bpublic\b|s-maxage/.test(cacheControl) || (cdnCacheControl !== '' && !/no-store|private/.test(cdnCacheControl))
    const markedPrivate = /\bprivate\b|\bno-store\b/.test(cacheControl)

    if (sharedCacheable || !markedPrivate) {
      failed = true
      console.error(`FAIL ${path}: cache-control="${cacheControl}" cdn-cache-control="${cdnCacheControl}"`)
    } else {
      console.log(`ok   ${path}: ${cacheControl}`)
    }
  }
  if (failed) process.exitCode = 1
}

main()

Script này không chứng minh CDN an toàn tuyệt đối, nhưng nó bắt đúng lỗi phổ biến nhất: một thay đổi cấu hình vô tình làm route riêng tư có header cho phép shared cache. Kết hợp với một bài kiểm tra thủ công định kỳ: đăng nhập hai tài khoản trên hai trình duyệt, mở cùng trang, so sánh nội dung và Age.

Những lỗi thường gặp

Dùng no-cache với ý "đừng lưu".

# Sai: vẫn được lưu, chỉ là phải revalidate
Cache-Control: no-cache
# Đúng cho dữ liệu nhạy cảm
Cache-Control: no-store

TTL dài cho file không có hash. max-age=604800 cho /checkout.js nghĩa là bản sửa lỗi tới tay user sau tối đa một tuần. Hoặc thêm hash vào tên file, hoặc dùng no-cache kèm ETag.

public trên API dùng token.

# Sai: cho phép CDN lưu response của request có Authorization
Cache-Control: public, max-age=60
# Đúng
Cache-Control: private, no-cache

Trông chờ Vary: Cookie để cache theo user. Có CDN từ chối cache, có CDN bỏ qua Vary. Tách phần cá nhân hóa ra request riêng.

Cache HTML lâu ở trình duyệt. HTML cũ trỏ tới chunk JS của bản build cũ; sau deploy, nếu asset cũ không còn được phục vụ, user gặp lỗi tải chunk. HTML để trình duyệt luôn revalidate.

Test cache với DevTools đang bật "Disable cache", hoặc bằng nút reload. Kết quả không phản ánh trải nghiệm thật. Dùng curl hoặc điều hướng bình thường.

Purge bằng xóa cứng cho tag rộng. dangerouslyDeleteByTag('products') trên trang chủ đông khách có thể dồn hàng nghìn request về origin cùng lúc. Dùng invalidate.

Rule Cache-Control wildcard trong next.config. Một dòng source: '/:path*' có thể biến trang dynamic thành trang được CDN cache. Để Next.js chọn header cho page, chỉ ghi đè cho path liệt kê rõ.

Checklist

  • Liệt kê mọi loại response của app và gán header theo bảng khuyến nghị; không route nào thiếu Cache-Control tường minh.
  • Mọi asset JS/CSS/font có hash hoặc version trong URL trước khi cho max-age dài và immutable.
  • HTML: trình duyệt max-age=0, must-revalidate; CDN dùng s-maxage hoặc CDN-Cache-Control kèm stale-while-revalidate và stale-if-error.
  • Mọi route đọc session trả private, no-store hoặc private, no-cache; không có public, s-maxage hay CDN-Cache-Control cho phép lưu.
  • Không có rule Cache-Control dạng source: '/:path*' trong next.config hay vercel.json.
  • Endpoint JSON công khai có ETag và trả 304 đúng; ETag sinh từ nội dung hoặc version, không từ thông tin của từng máy.
  • Gắn cache tag cho response phụ thuộc dữ liệu CMS/catalog và purge bằng invalidate qua webhook.
  • Bỏ tham số tracking (utm_*) khỏi cache key ở CDN.
  • Thêm script kiểm tra header của route riêng tư vào CI sau mỗi deploy preview.
  • Khi debug, chạy curl -D - ba lần liên tiếp và đọc Age, x-vercel-cache hoặc cf-cache-status trước khi kết luận.

Đọc thêm

Last updated: July 17, 2025

Comments