karawaci.kode

2026-08-09 · 8 min

CDN Stale-While-Revalidate dengan Cloudflare Workers untuk Konten Semi-Dinamis

Klien e-commerce di Jakarta datang ke saya dengan masalah klasik: halaman listing produk mereka lambat di jam sibuk karena setiap request memukul origin Postgres, tapi mereka tidak mau cache TTL pendek karena stok bisa berubah. Saya pasang stale-while-revalidate di Cloudflare Workers — hasilnya latency turun dari rata-rata 800ms ke di bawah 50ms, dan data stok tidak pernah telat lebih dari 30 detik.

Masalah dengan dua ekstrem

Ketika berbicara soal caching untuk konten semi-dinamis, timnya sering terjebak di dua ekstrem yang sama-sama bermasalah.

Ekstrem pertama: cache TTL panjang. Pasang TTL 10 menit, CDN melayani semua request dari cache. Latency bagus. Tapi saat ada update harga atau perubahan stok, user bisa melihat data lama selama 10 menit penuh. Untuk konten bisnis, ini tidak bisa diterima.

Ekstrem kedua: no-cache atau TTL sangat pendek. Setiap request atau hampir setiap request langsung ke origin. Data selalu fresh. Tapi origin kewalahan, biaya infrastruktur naik, dan latency naik lagi karena request harus melintasi jarak geografis ke data center.

Stale-while-revalidate adalah jalan tengah yang sebenarnya. Header Cache-Control: max-age=30, stale-while-revalidate=60 memberitahu CDN: “Sajikan cache maksimal 30 detik. Kalau sudah expired tapi masih dalam 60 detik berikutnya, tetap sajikan yang lama ke user, tapi refresh di background secara paralel.”

Masalahnya di Cloudflare: dukungan stale-while-revalidate di cache Cloudflare CDN biasa tidak selalu berperilaku konsisten, dan Anda tidak punya kontrol penuh atas logikanya. Di sinilah Workers masuk — Anda implementasikan sendiri logikanya dengan Cache API, dan Anda punya kontrol penuh.

Setup: Cloudflare Worker dengan Cache API

Pertama, inisialisasi project:

npm create cloudflare@latest semi-dynamic-cache -- --type worker
cd semi-dynamic-cache

Worker ini akan menjadi proxy cerdas antara user dan origin. Berikut struktur dasarnya di src/index.ts:

export interface Env {
  ORIGIN_URL: string;
}

const FRESH_TTL = 30;        // detik — cache dianggap fresh
const STALE_TTL = 90;        // detik — window stale-while-revalidate

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const cache = caches.default;
    const cacheKey = new Request(request.url, { method: 'GET' });

    // 1. Cek cache
    const cached = await cache.match(cacheKey);

    if (cached) {
      const age = getCacheAge(cached);

      if (age < FRESH_TTL) {
        // Masih fresh — kembalikan langsung
        return setCacheStatus(cached, 'HIT-FRESH');
      }

      if (age < FRESH_TTL + STALE_TTL) {
        // Stale tapi masih dalam window — kembalikan stale, revalidasi di background
        ctx.waitUntil(revalidate(cacheKey, env.ORIGIN_URL, cache));
        return setCacheStatus(cached, 'HIT-STALE');
      }
    }

    // Cache miss atau sudah terlalu tua — fetch dari origin, blokir
    return fetchAndCache(cacheKey, env.ORIGIN_URL, cache);
  },
};

Dua helper kritis: getCacheAge membaca header custom yang kita simpan saat caching, dan revalidate menggunakan ctx.waitUntil() agar eksekusinya tidak dimatikan saat respons sudah dikirim ke user.

function getCacheAge(response: Response): number {
  const cachedAt = response.headers.get('X-Cached-At');
  if (!cachedAt) return Infinity;
  return (Date.now() - parseInt(cachedAt, 10)) / 1000;
}

function setCacheStatus(response: Response, status: string): Response {
  const headers = new Headers(response.headers);
  headers.set('X-Cache-Status', status);
  return new Response(response.body, { ...response, headers });
}

async function fetchAndCache(
  cacheKey: Request,
  originUrl: string,
  cache: Cache,
): Promise<Response> {
  const originPath = new URL(cacheKey.url).pathname + new URL(cacheKey.url).search;
  const originResponse = await fetch(new Request(originUrl + originPath));

  if (!originResponse.ok) {
    return originResponse; // jangan cache error
  }

  // Cek apakah origin melarang caching konten ini
  const cacheControl = originResponse.headers.get('Cache-Control') ?? '';
  if (cacheControl.includes('private') || cacheControl.includes('no-store')) {
    return originResponse;
  }

  const headers = new Headers(originResponse.headers);
  headers.set('X-Cached-At', Date.now().toString());
  // Paksa CDN tidak menyimpan cache kontrolnya sendiri — kita yang kontrol
  headers.set('Cache-Control', 'public, max-age=31536000');

  const cachedResponse = new Response(originResponse.body, {
    status: originResponse.status,
    headers,
  });

  await cache.put(cacheKey, cachedResponse.clone());

  const returnHeaders = new Headers(headers);
  returnHeaders.set('X-Cache-Status', 'MISS');
  return new Response(cachedResponse.body, { status: cachedResponse.status, headers: returnHeaders });
}

async function revalidate(
  cacheKey: Request,
  originUrl: string,
  cache: Cache,
): Promise<void> {
  try {
    await fetchAndCache(cacheKey, originUrl, cache);
  } catch (err) {
    // Revalidasi gagal — biarkan, cache lama tetap ada sampai dicoba lagi
    console.error('Revalidation failed:', err);
  }
}

Konfigurasi wrangler.toml

name = "semi-dynamic-cache"
main = "src/index.ts"
compatibility_date = "2026-07-01"

[vars]
ORIGIN_URL = "https://api.yourdomain.com"

[[routes]]
pattern = "cdn.yourdomain.com/api/products/*"
zone_name = "yourdomain.com"

Deploy ke production:

npx wrangler deploy

Verifikasi behavior dengan curl

Setelah deploy, Anda bisa memverifikasi logikanya langsung:

# Request pertama — harus MISS, origin dipukul
curl -I https://cdn.yourdomain.com/api/products/listing
# X-Cache-Status: MISS

# Dalam 30 detik — harus HIT-FRESH
curl -I https://cdn.yourdomain.com/api/products/listing
# X-Cache-Status: HIT-FRESH

# Setelah 30 detik, sebelum 120 detik — stale, revalidasi background
curl -I https://cdn.yourdomain.com/api/products/listing
# X-Cache-Status: HIT-STALE

# Request berikutnya setelah revalidasi selesai — fresh lagi
curl -I https://cdn.yourdomain.com/api/products/listing
# X-Cache-Status: HIT-FRESH

Header X-Cache-Status ini yang saya rekomendasikan untuk masuk ke Grafana dashboard. Di production kami, saya track rasio MISS terhadap total request — kalau naik di atas 5%, ada yang tidak beres dengan cache.

Isolasi cache per Cloudflare PoP

Satu hal yang sering luput: Cache API di Workers bersifat per-PoP (Point of Presence). Cache di Singapore dan cache di Amsterdam adalah dua cache yang terpisah. Setelah deploy awal atau setelah cache expired, tiap PoP akan melakukan MISS sendiri-sendiri ke origin saat ada request pertama dari region tersebut.

Untuk konten yang sama di semua region ini bukan masalah. Tapi kalau origin Anda lambat merespons dan request pertama di tiap PoP terasa lambat, pertimbangkan cache warming — hit endpoint dari tiap region sesaat setelah deploy:

# Hit dari beberapa region lewat synthetic monitoring
# Checkly, Uptime Robot, atau script sederhana
ENDPOINTS=(
  "https://cdn.yourdomain.com/api/products/listing"
  "https://cdn.yourdomain.com/api/products/featured"
)

for url in "${ENDPOINTS[@]}"; do
  curl "$url" --silent -o /dev/null -w "%{url_effective}: %{http_code} (%{time_total}s)\n"
done

Jalankan script ini dari CI/CD pipeline Anda setelah wrangler deploy selesai.

Trade-off yang harus Anda terima

Pola ini bukan tanpa biaya.

User pertama setelah window stale berakhir tetap menunggu. Kalau window stale-while-revalidate habis — misalnya konten tidak diakses lebih dari 120 detik — request berikutnya akan memblokir ke origin. Ini bisa dimitigasi dengan window stale yang lebih panjang untuk konten yang jarang diakses, atau background revalidasi periodik via Cron Triggers Workers.

Data bisa telat. Ini trade-off inti. User yang request tepat saat revalidasi baru mulai berjalan di background akan mendapat data yang sudah 30+ detik stale. Untuk dashboard finansial atau data real-time, ini tidak dapat diterima — gunakan SSE atau WebSocket langsung ke origin.

Debugging lebih kompleks. Ketika ada laporan “data saya tidak update”, Anda perlu cek header X-Cache-Status, cek kapan revalidasi terakhir berjalan, dan cek apakah Workers error log menunjukkan revalidasi yang gagal. Setup Cloudflare Logpush ke R2 atau ke observability stack dari awal, jangan tunggu ada insiden.

Jangan cache respons yang mengandung data user. Ini risiko keamanan kritis. Kalau origin Anda merespons dengan data yang berbeda per user — session, personalisasi, saldo — pastikan Anda tidak menyimpan ke cache. Cek header Authorization atau Cookie di request masuk sebelum mencari di cache:

// Di awal handler fetch, sebelum cache.match
const authHeader = request.headers.get('Authorization');
const cookieHeader = request.headers.get('Cookie');
if (authHeader || cookieHeader) {
  // Bypass cache sepenuhnya untuk request authenticated
  return fetch(new Request(env.ORIGIN_URL + new URL(request.url).pathname));
}

Kapan ini adalah pilihan tepat

Saya rekomendasikan stale-while-revalidate di Workers untuk konten yang memenuhi tiga kriteria: (1) diakses dengan frekuensi tinggi dari banyak user, bukan per-user; (2) toleran terhadap data yang telat beberapa detik sampai beberapa menit; (3) cost memanggil origin cukup signifikan — query Postgres yang berat, limit rate API pihak ketiga, atau CPU time yang mahal.

Untuk klien Jakarta tadi, halaman listing produk memenuhi semua kriteria itu. Data stok boleh telat 30 detik, diakses ribuan kali per jam oleh user anonim, dan query Postgres-nya berat karena ada join ke tabel inventory. Setelah Workers terpasang, origin hits turun 95% di jam sibuk dan p99 latency konsisten di bawah 80ms dari region Asia Tenggara.

Kalau konten Anda tidak memenuhi kriteria itu — terutama kalau ada personalisasi atau data real-time — jangan pakai pola ini. Biarkan dynamic content tetap dynamic, dan optimalkan origin-nya langsung lewat query optimization, connection pooling, atau PgBouncer. Menambah cache layer di atas origin yang lambat hanya menyembunyikan masalah, bukan menyelesaikannya.

Ditulis oleh Reza Pradipta