2026-06-26 · 7 min
Caching Strategy: Redis + CDN di SaaS 2026
Enam bulan lalu, SaaS invoice multi-tenant saya — 1.200 tenant aktif, ~85k DAU — mulai groaning di jam sibuk (10:00-11:30 WIB). P95 dashboard endpoint nyentuh 220ms, P99 di 480ms. Postgres CPU steady di 78%. Saya tahu jawabannya: caching. Tapi caching yang naive akan bikin masalah baru (stale data, thundering herd, cache stampede).
Ini cerita layering Redis + CDN, angka real, dan bug yang bikin saya cabut subuh.
Konteks setup
- Stack: Bun 1.3 + Hono, Postgres 17, Redis 7.4, Cloudflare CDN di depan API.
- Traffic: ~3.500 RPS peak, ~1.200 tenant, 60% authenticated, 40% public (invoice link share).
- Origin: 2x Hetzner CPX41 (8 vCPU, 16GB) di Helsinki, edge POP terdekat Jakarta-Singapore (~38ms RTT).
- Sebelum caching: P50 78ms, P95 220ms, P99 480ms. Postgres baseline 78% CPU.
Saya pernah migrasi Bun di payment gateway, jadi ekosistemnya familiar. Yang beda di SaaS ini: multi-tenant, jadi cache key strategy harus dipikirin dari awal.
Layer 1: Cloudflare CDN untuk public paths
Public invoice link (/i/:slug) di-share lewat WhatsApp. Hit ratio kandidat tinggi karena satu link bisa di-tap 50-200 kali sebelum payment confirmed.
Konfigurasi Page Rule:
Cache Level: Cache Everything
Edge Cache TTL: 5 minutes
Browser Cache TTL: 1 minute
Tambah header response di origin:
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60
Vary: Accept-Encoding
stale-while-revalidate=60 ini critical — saat invoice di-update (status paid), edge kasih versi lama selama 60 detik sambil fetch versi baru background. User experience seamless, origin tidak di-spike.
Hasil layer 1 (minggu kedua):
- CDN hit ratio public path: 89%
- Origin bandwidth turun dari 1.8TB/bulan ke 640GB/bulan (~64% reduction)
- Cloudflare cost: $20/bulan (Pro) tetap, tapi origin bandwidth Hetzner overage hilang (~Rp 480k/bulan saved)
Layer 2: Redis untuk authenticated dashboard
Authenticated path tidak bisa di-CDN cache (per-user data). Di sini Redis masuk.
Key naming convention yang saya pakai:
tenant:{tenant_id}:dashboard:summary:v{version}
tenant:{tenant_id}:invoice:list:p{page}:v{version}
tenant:{tenant_id}:invoice:{invoice_id}:v{version}
Version increment di setiap write. Old key tidak di-DELETE — biar LRU evict natural. Ini menghindari race condition saat invalidation (delete + write concurrent bisa kasih stale data).
// Read pattern
const version = await redis.get(`tenant:${tid}:dashboard:version`) ?? '1';
const cacheKey = `tenant:${tid}:dashboard:summary:v${version}`;
const cached = await redis.get(cacheKey);
if (cached) {
metrics.cacheHit.inc({ path: 'dashboard' });
return JSON.parse(cached);
}
// Cache miss: compute + set with TTL
const data = await computeDashboard(tid);
await redis.setex(cacheKey, 300, JSON.stringify(data));
return data;
TTL 5 menit karena dashboard summary tolerable stale 5 menit (user paham angka di-refresh, bukan real-time trading).
Thundering herd: lockless via probabilistic early refresh
Saat cache expire bersamaan untuk tenant besar (1 tenant punya 50 concurrent session), 50 request hit Postgres simultan. Saya pakai pattern XFetch (probabilistic early expiration):
// Refresh probabilistically before TTL expires
const ttlRemaining = await redis.ttl(cacheKey);
const beta = 1.0; // tunable
const delta = computeTimeMs / 1000; // last compute duration
const shouldRefresh = Math.random() < Math.exp(-beta * ttlRemaining / delta);
if (shouldRefresh && ttlRemaining < 60) {
// Background refresh, don't block response
queueMicrotask(() => refreshCache(tid));
}
Setelah deploy XFetch: thundering herd events (Postgres CPU spike > 95% dalam < 2 detik) turun dari 8-12 per hari ke 0-1 per hari.
Layer 3: in-process LRU untuk hot keys
Hot keys (tenant top-10 yang ~38% traffic) saya cache lagi di-process pakai lru-cache dengan TTL 30 detik. Roundtrip Redis 0.8-1.2ms, in-process 0.02ms. Untuk endpoint yang dipanggil 200 RPS per tenant, ini matter.
import { LRUCache } from 'lru-cache';
const localCache = new LRUCache<string, any>({
max: 5000,
ttl: 30_000, // 30s
});
Cost: 2 worker process × 5000 entries × ~2KB avg = ~20MB extra RSS. Acceptable.
Hasil akhir setelah 4 minggu tuning
| Metric | Sebelum | Sesudah |
|---|---|---|
| P50 | 78ms | 18ms |
| P95 | 220ms | 38ms |
| P99 | 480ms | 95ms |
| Postgres CPU avg | 78% | 31% |
| Origin bandwidth | 1.8 TB/mo | 640 GB/mo |
| Redis memory | - | 1.2 GB |
| Cache hit ratio gabungan | - | 92% |
| Monthly cost change | baseline | -Rp 720k (bandwidth + RDS sizing) |
Yang break
1. Cache key collision saat tenant rename
Tenant ganti subdomain → cache key tetap pakai tenant_id (UUID), aman. Tapi ada satu endpoint legacy yang pakai tenant:{subdomain}:.... Saat rename, cache lama tidak invalidate. Fix: refactor semua cache key ke tenant_id, audit script untuk detect string-based keys.
2. Redis OOM di hari Senin pagi
Senin pagi traffic spike 2.3x dari rata-rata. Cache fill rate exceeds eviction rate selama 8 menit. Redis OOM, instance restart, semua cache cold. Postgres CPU langsung 95%, P99 jadi 1.8s selama 4 menit.
Fix: upgrade Redis instance dari 2GB ke 4GB, set maxmemory-policy allkeys-lru (sebelumnya noeviction). noeviction itu jebakan — kalau penuh, write Redis fail, dan kode saya tidak handle path itu.
3. Stale-while-revalidate kasih invoice “paid” yang sebenarnya sudah refund
Edge case: customer bayar, lalu refund dalam < 60 detik. SWR window kasih versi “paid” ke user lain yang lihat link. Cuma 3 kasus dalam 6 bulan, tapi confusing untuk customer support.
Fix: untuk status transition critical (refund, cancel), explicit purge via Cloudflare API:
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \
-H "Authorization: Bearer $TOKEN" \
-d '{"files":["https://api.kami.id/i/'"$slug"'"]}'
Latency purge ~1.2 detik global. Acceptable untuk edge case ini.
4. Monitoring blind spot
Selama 3 minggu saya tidak monitor cache hit ratio per-endpoint, cuma global. Endpoint /api/reports/monthly ternyata hit ratio cuma 12% (heavy filter combinations, low key reuse). Saya buang dari Redis caching, balik ke direct query dengan materialized view di Postgres. P95 endpoint itu lebih bagus tanpa cache (45ms) dibanding dengan cache miss penalty (180ms avg).
Pelajaran: bukan semua endpoint cocok untuk cache. Ukur per-endpoint hit ratio. Kalau < 40%, evaluate ulang.
Verdict
Layering CDN + Redis + in-process LRU memang overhead engineering — 3 sumber kebenaran, 3 invalidation strategy, 3 failure mode. Tapi untuk SaaS multi-tenant Indonesia dengan profil traffic share-link (public) + dashboard (auth) + hot tenants (heavy reads): layering ini berikan latency dan cost yang konsisten.
Bukan magic. Yang penting: ukur hit ratio per-endpoint, jangan blanket-cache, dan punya rollback plan saat Redis ngambek (cache-aside pattern dengan fallback ke direct query, bukan error 500).
Untuk konteks teknik invalidation versioning, mirip pendekatan di migrasi Astro content collections — versioning di key, bukan delete-on-write.
Ditulis oleh Reza Pradipta