Web Performance Deep Dive

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=300

Nghĩ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 response

Ví 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-revalidate giố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 cache

Cloudflare 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:

  1. Render đầu tiên: check cache → nếu có data cũ, render ngay + fetch mới ở background
  2. Fetch xong: so sánh với data cũ → nếu khác thì re-render
  3. 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-store cho 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
ThpThp                 │  No cache / short max-age
Bất kỳ            │  Zero                 │  no-store + real-time

Stale-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.


Có câu hỏi hay góp ý? Reach out trên Twitter hoặc GitHub.