stale-while-revalidate — Caching strategy ai cũng nghe nhưng ít ai hiểu đúng
Bạn search "caching strategy" và thấy mọi bài đều nhắc stale-while-revalidate. "Serve cache cũ, refresh background, user thấy data ngay" — nghe đơn giản. Nhưng khi implement thực tế: cache invalidation race conditions, thundering herd khi cache expire đồng loạt, stale data hiển thị quá lâu vì revalidation fail silently.
Bài này đi sâu vào stale-while-revalidate ở 2 tầng: HTTP caching (browser + CDN) và application-level (SWR pattern trong code).
HTTP Cache-Control: stale-while-revalidate
Cú pháp
Cache-Control: max-age=60, stale-while-revalidate=300Nghĩa là:
- 0-60 giây: Cache fresh → serve trực tiếp, không request server
- 60-360 giây: Cache stale → serve cache ngay cho user, đồng thời revalidate với server ở background
- Sau 360 giây: Cache quá stale → phải chờ server response mới serve
Timeline trực quan:
Request ──────────────────────────────────────→ time
|← fresh (60s) →|← stale-ok (300s) →|← must revalidate →|
Serve cache Serve cache + Wait for
instantly background fetch fresh responseVí dụ thực tế
API endpoint trả về product listing, data thay đổi vài phút một lần:
// Express.js
app.get('/api/products', (req, res) => {
const products = getProducts();
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.json(products);
});User đầu tiên sau 60 giây: thấy data cũ ngay (nhanh), server cập nhật cache ở background. User tiếp theo: thấy data mới. Không ai phải chờ.
CDN behavior
Khi đặt sau CDN (Cloudflare, Fastly, CloudFront), stale-while-revalidate hoạt động ở edge:
User → CDN Edge → Origin Server
↓
Cache stale?
├── Yes, within SWR window:
│ ├── Serve stale response to user (instant)
│ └── Background revalidate with origin
└── No, cache fresh:
└── Serve cached response (instant)⚠️ Không phải mọi CDN interpret
stale-while-revalidategiống nhau. Cloudflare respect header này. CloudFront trước đây bỏ qua (giờ đã support). Luôn test behavior cụ thể với CDN bạn dùng.
Thundering herd — vấn đề khi cache expire
Khi cache hết hạn và 1000 requests đến cùng lúc — tất cả đều thấy cache stale, tất cả đều trigger revalidation. Origin server nhận 1000 requests thay vì 1. Đây gọi là thundering herd (hay cache stampede).
Giải pháp: Request coalescing
CDN tốt sẽ coalesce (gộp) multiple revalidation requests thành 1:
1000 users hit stale cache simultaneously
├── CDN serves stale to all 1000 users (instant)
├── CDN sends ONLY 1 revalidation request to origin
└── Origin responds once → CDN updates cacheCloudflare gọi đây là "request collapsing" — mặc định bật. Nếu CDN không hỗ trợ, bạn cần handle ở application layer.
Application-level protection
// Mutex-based revalidation — chỉ 1 request fetch data mới
const locks = new Map();
async function getWithSWR(key, fetchFn, { maxAge, swr }) {
const cached = cache.get(key);
const now = Date.now();
if (cached && now - cached.timestamp < maxAge) {
// Fresh — serve directly
return cached.data;
}
if (cached && now - cached.timestamp < maxAge + swr) {
// Stale but within SWR window — serve stale + background revalidate
if (!locks.has(key)) {
// Chỉ 1 caller được revalidate
locks.set(key, true);
fetchFn()
.then(data => {
cache.set(key, { data, timestamp: Date.now() });
})
.catch(err => {
console.error(`Revalidation failed for ${key}:`, err);
// Giữ stale data — tốt hơn là error
})
.finally(() => {
locks.delete(key);
});
}
return cached.data;
}
// Quá stale — phải chờ fresh data
const data = await fetchFn();
cache.set(key, { data, timestamp: Date.now() });
return data;
}💡 Khi revalidation fail, giữ stale data. Stale data > error page. Đây là lý do SWR pattern resilient hơn cache-then-network thuần.
SWR ở frontend — React hooks pattern
Library swr (Vercel) và @tanstack/react-query implement SWR pattern ở client:
useSWR cơ bản
import useSWR from 'swr';
function ProductList() {
const { data, error, isLoading, isValidating } = useSWR(
'/api/products',
(url) => fetch(url).then(r => r.json()),
{
revalidateOnFocus: true, // Refetch khi user quay lại tab
revalidateOnReconnect: true, // Refetch khi có mạng lại
dedupingInterval: 2000, // Dedup requests trong 2s
refreshInterval: 0, // Không auto refresh (set > 0 để poll)
}
);
if (isLoading) return <ProductSkeleton />;
if (error) return <ErrorState />;
return (
<div>
{isValidating && <RefreshIndicator />}
{data.map(product => <ProductCard key={product.id} product={product} />)}
</div>
);
}Flow:
- Render đầu tiên: check cache → nếu có data cũ, render ngay + fetch mới ở background
- Fetch xong: so sánh với data cũ → nếu khác thì re-render
- User navigate đi rồi quay lại: serve cache + revalidate
Optimistic updates
SWR pattern kết hợp tốt với optimistic UI:
import useSWR, { mutate } from 'swr';
async function addToCart(productId) {
const newItem = { id: productId, quantity: 1 };
// 1. Optimistic update — UI phản hồi ngay
mutate('/api/cart', (currentCart) => ({
...currentCart,
items: [...currentCart.items, newItem],
}), { revalidate: false });
try {
// 2. Gửi request thật
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId }),
});
// 3. Revalidate để đồng bộ với server
mutate('/api/cart');
} catch (error) {
// 4. Rollback nếu fail
mutate('/api/cart');
showToast('Không thể thêm vào giỏ hàng');
}
}User bấm "Thêm vào giỏ" → cart badge tăng ngay (optimistic) → API call ở background → nếu fail thì rollback. UX mượt, data consistent.
Cache invalidation — bài toán muôn thuở
"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton
SWR giảm bớt pain nhưng không giải quyết hoàn toàn. Một số patterns:
Event-driven invalidation
// Khi product được update trên admin panel
app.put('/api/products/:id', async (req, res) => {
await db.products.update(req.params.id, req.body);
// Invalidate CDN cache
await cloudflare.purgeCache({
files: [
`https://api.example.com/api/products/${req.params.id}`,
'https://api.example.com/api/products', // List cũng cần invalidate
],
});
res.json({ success: true });
});Tag-based invalidation
// Set cache với tags
app.get('/api/products', (req, res) => {
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
res.set('Cache-Tag', 'products, storefront');
res.json(products);
});
app.get('/api/products/:id', (req, res) => {
res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=600');
res.set('Cache-Tag', `product-${req.params.id}, products`);
res.json(product);
});
// Invalidate by tag — purge tất cả responses có tag "products"
await cdn.purgeTag('products');Fastly và Cloudflare Enterprise hỗ trợ tag-based purging. Powerful hơn purge by URL vì một action invalidate tất cả related resources.
Version-based cache key
Pattern đơn giản nhưng hiệu quả cho static assets:
// Embed content hash trong URL → immutable caching
app.get('/api/config', (req, res) => {
const config = getConfig();
const hash = crypto.createHash('md5')
.update(JSON.stringify(config))
.digest('hex')
.slice(0, 8);
// Redirect sang versioned URL
res.redirect(`/api/config/${hash}`);
});
app.get('/api/config/:hash', (req, res) => {
// Immutable — cache forever
res.set('Cache-Control', 'public, max-age=31536000, immutable');
res.json(getConfig());
});Khi nào KHÔNG dùng SWR
SWR phù hợp cho data có tolerance với staleness. Nhưng có những trường hợp stale data không chấp nhận được:
- Financial transactions: Số dư tài khoản phải real-time. SWR hiện số cũ → user nghĩ còn tiền → giao dịch fail
- Inventory count: Còn "1 sản phẩm" (stale) → 2 user cùng mua → oversell
- Real-time collaboration: Document editing cần consistency, không phải eventual consistency
Cho những case này, dùng:
- WebSocket / Server-Sent Events cho real-time updates
- Optimistic locking (version field) cho write operations
Cache-Control: no-storecho data không bao giờ nên cache
Tổng kết strategy
Read frequency │ Staleness tolerance │ Strategy
──────────────────┼───────────────────────┼──────────────────────
Rất cao (>100/s) │ Cao (phút) │ CDN + SWR header
Cao (>10/s) │ Trung bình (giây) │ App cache + SWR
Thấp │ Thấp │ No cache / short max-age
Bất kỳ │ Zero │ no-store + real-timeStale-while-revalidate không phải silver bullet, nhưng là default strategy tốt nhất cho đa số API endpoints. User thấy data ngay, server không bị overwhelm, và data eventually consistent. Trick là hiểu rõ SWR window phù hợp cho từng loại data — 5 giây cho stock price khác rất xa 5 phút cho blog listing.