2026-07-04 · 11 min
Strategi Caching Redis di Node.js/Express untuk Production
Endpoint /api/products di salah satu proyek e-commerce yang saya pegang mulai lambat begitu katalog tembus puluhan ribu SKU. P95 endpoint itu naik ke kisaran 300-400ms karena setiap request melakukan join dan agregasi yang sama berulang-ulang, padahal datanya jarang berubah. Jawabannya klasik: caching dengan Redis. Tapi caching yang asal tempel justru memunculkan kelas bug baru — data basah, stampede saat traffic naik, sampai memory Redis penuh.
Artikel ini adalah panduan praktis pola caching Redis di Node.js/Express yang benar-benar aman dipakai di production: cache-aside sebagai fondasi, cara menentukan TTL, invalidation yang tidak bikin data basah, plus pitfall yang paling sering saya temui.
Kenapa cache-aside, bukan yang lain
Ada beberapa pola caching (read-through, write-through, write-behind), tapi untuk mayoritas aplikasi Express, cache-aside adalah pilihan default yang tepat. Alasannya:
- Eksplisit: logika baca-tulis cache ada di kode Anda, bukan tersembunyi di library. Mudah di-debug dan di-trace.
- Resilient: kalau Redis down, aplikasi bisa fallback ke database. Cache adalah optimasi, bukan sumber kebenaran.
- Selektif: Anda cache hanya yang perlu, bukan semua query.
Alur cache-aside sederhana:
- Request masuk, cek Redis untuk key tertentu.
- Hit → return data dari Redis.
- Miss → query database, simpan hasil ke Redis dengan TTL, return.
Setup koneksi Redis yang benar
Pertama, jangan bikin koneksi Redis baru per request. Buat satu client yang di-share, dengan retry strategy dan error handler agar proses tidak crash saat Redis sempat detik unreachable.
Contoh dengan ioredis (paling umum dipakai di ekosistem Node):
// redis.ts
import Redis from 'ioredis';
export const redis = new Redis({
host: process.env.REDIS_HOST ?? '127.0.0.1',
port: Number(process.env.REDIS_PORT ?? 6379),
// Jangan biarkan command menggantung selamanya saat Redis lambat
commandTimeout: 500,
maxRetriesPerRequest: 2,
// Exponential backoff untuk reconnect
retryStrategy(times) {
const delay = Math.min(times * 200, 2000);
return delay;
},
});
// PENTING: tanpa handler ini, error koneksi bisa jadi unhandled
// dan meng-crash proses Node.
redis.on('error', (err) => {
console.error('[redis] connection error', err.message);
});
commandTimeout yang eksplisit itu krusial. Tanpa ini, saat Redis lambat merespons, request Express Anda ikut menggantung — cache yang seharusnya mempercepat malah memperlambat.
Helper cache-aside yang reusable
Daripada menulis pola get-or-set di setiap route, bungkus jadi satu helper. Perhatikan bagian fallback: kalau Redis error, kita tetap jalankan fetcher ke database, bukan melempar 500.
// cache.ts
import { redis } from './redis';
interface CacheOptions {
ttlSeconds: number;
}
export async function cacheAside<T>(
key: string,
fetcher: () => Promise<T>,
opts: CacheOptions,
): Promise<T> {
// 1. Coba baca dari cache. Bungkus dengan try/catch:
// Redis down TIDAK boleh menjatuhkan request.
try {
const cached = await redis.get(key);
if (cached !== null) {
return JSON.parse(cached) as T;
}
} catch (err) {
console.warn('[cache] read failed, fallback ke source', (err as Error).message);
}
// 2. Cache miss (atau Redis error): ambil dari sumber.
const fresh = await fetcher();
// 3. Simpan ke cache dengan TTL. Gagal simpan bukan fatal.
try {
await redis.set(key, JSON.stringify(fresh), 'EX', opts.ttlSeconds);
} catch (err) {
console.warn('[cache] write failed', (err as Error).message);
}
return fresh;
}
Penggunaannya di route Express jadi ringkas:
// routes/products.ts
import { Router } from 'express';
import { cacheAside } from '../cache';
import { getProductsFromDb } from '../db/products';
const router = Router();
router.get('/api/products', async (req, res, next) => {
try {
const page = Number(req.query.page ?? 1);
const key = `products:list:page:${page}:v1`;
const products = await cacheAside(
key,
() => getProductsFromDb({ page }),
{ ttlSeconds: 120 }, // 2 menit
);
res.json(products);
} catch (err) {
next(err);
}
});
export default router;
Menentukan TTL: prinsip, bukan angka ajaib
Pertanyaan “berapa TTL yang benar?” tidak punya jawaban tunggal. TTL adalah kompromi antara freshness dan hit ratio. Kerangka berpikir yang saya pakai:
| Jenis data | Karakter | TTL (gambaran umum) |
|---|---|---|
| Konfigurasi, feature flag | Jarang berubah, dibaca sangat sering | 5-60 menit |
| List/katalog produk | Berubah beberapa kali sehari | 1-5 menit |
| Dashboard summary | Toleran basah sesaat | 30-120 detik |
| Data per-user (profil, cart) | Berubah oleh aksi user | 10-60 detik + invalidate saat write |
| Harga/stok real-time | Tidak boleh basah | Jangan di-cache, atau TTL sangat pendek + invalidate |
Aturan praktis: TTL adalah jaring pengaman, bukan mekanisme utama. Untuk data yang bisa berubah karena aksi user, jangan mengandalkan TTL saja — kombinasikan dengan invalidation eksplisit (bagian berikutnya). TTL memastikan key basah tetap dibersihkan sendiri kalau invalidation Anda kelewat.
Satu tip: tambahkan sedikit jitter ke TTL agar key tidak expire barengan (mengurangi risiko stampede):
function ttlWithJitter(base: number): number {
// +/- 10% jitter
const jitter = Math.floor(base * 0.1 * (Math.random() * 2 - 1));
return base + jitter;
}
Invalidation: bagian yang paling sering salah
TTL menangani expiry pasif. Tapi saat user meng-update produk, Anda ingin cache langsung mencerminkan perubahan itu — tidak menunggu TTL habis. Ada dua strategi utama.
Strategi A: write-then-invalidate (delete key)
Setelah write ke database sukses, hapus key yang relevan. Miss berikutnya akan me-refresh cache dari data terbaru.
// routes/products.ts (lanjutan)
import { redis } from '../redis';
import { updateProductInDb } from '../db/products';
router.put('/api/products/:id', async (req, res, next) => {
try {
const id = req.params.id;
await updateProductInDb(id, req.body);
// Invalidate cache detail. Delete SETELAH write DB sukses.
await redis.del(`products:detail:${id}:v1`);
// Catatan: list produk juga perlu di-invalidate.
// Lihat pembahasan versioning di bawah.
res.json({ ok: true });
} catch (err) {
next(err);
}
});
Poin penting soal urutan: tulis ke database dulu, baru invalidate cache. Kalau Anda delete cache dulu lalu write DB, ada window di mana request lain bisa membaca DB lama dan mengisi ulang cache dengan data basah tepat sebelum write Anda selesai.
Strategi B: versioning key (untuk invalidasi banyak key sekaligus)
Masalah dengan Strategi A: bagaimana meng-invalidate semua halaman list (products:list:page:1, page:2, dst) saat satu produk berubah? Menghapus satu per satu ribet, dan jangan pakai KEYS products:list:* di production — perintah KEYS memblokir Redis sampai selesai men-scan seluruh keyspace.
Solusinya: simpan nomor versi, dan sisipkan versi itu ke dalam key. Naikkan versi saat write; semua key lama otomatis di-abaikan dan ter-evict oleh LRU.
// cache-version.ts
import { redis } from './redis';
async function getListVersion(): Promise<string> {
const v = await redis.get('products:list:version');
return v ?? '1';
}
export async function buildListKey(page: number): Promise<string> {
const v = await getListVersion();
return `products:list:page:${page}:v${v}`;
}
// Panggil ini saat produk dibuat/diubah/dihapus:
export async function bumpListVersion(): Promise<void> {
await redis.incr('products:list:version');
}
Dengan versioning, invalidasi seluruh list cukup satu perintah INCR — O(1), tidak ada scan. Key lama tidak perlu dihapus manual; biarkan kebijakan eviction membersihkannya. Pendekatan versioning ini mirip yang saya bahas di layering Redis + CDN di SaaS: versi di key, bukan delete-on-write, menghindari race condition invalidation.
Kalau memang butuh menghapus banyak key by pattern (misalnya cleanup), gunakan SCAN (non-blocking, incremental), bukan KEYS:
export async function deleteByPattern(pattern: string): Promise<void> {
let cursor = '0';
do {
const [next, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = next;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
}
Mencegah cache stampede
Saat key populer expire dan traffic tinggi, puluhan request bisa miss bersamaan dan semuanya menghantam database — inilah cache stampede (thundering herd). Untuk katalog produk yang di-request ratusan kali per menit, ini bisa memicu lonjakan CPU database yang tajam.
Cara paling sederhana dan efektif: single-flight lock dengan SET NX. Hanya satu request yang boleh me-recompute; sisanya menunggu sebentar lalu membaca ulang cache.
// cache-singleflight.ts
import { redis } from './redis';
export async function cacheAsideLocked<T>(
key: string,
fetcher: () => Promise<T>,
ttlSeconds: number,
): Promise<T> {
const cached = await redis.get(key);
if (cached !== null) return JSON.parse(cached) as T;
const lockKey = `lock:${key}`;
// Coba ambil lock selama 5 detik. NX = hanya set kalau belum ada.
const gotLock = await redis.set(lockKey, '1', 'EX', 5, 'NX');
if (gotLock === 'OK') {
try {
const fresh = await fetcher();
await redis.set(key, JSON.stringify(fresh), 'EX', ttlSeconds);
return fresh;
} finally {
await redis.del(lockKey);
}
}
// Tidak dapat lock: request lain sedang recompute.
// Tunggu sebentar lalu baca ulang cache.
await new Promise((r) => setTimeout(r, 50));
const retry = await redis.get(key);
if (retry !== null) return JSON.parse(retry) as T;
// Fallback terakhir: hitung sendiri (jangan biarkan request gagal).
return fetcher();
}
Alternatif yang lebih halus adalah probabilistic early expiration (pola XFetch): refresh cache di background sebelum TTL benar-benar habis, sehingga tidak pernah ada momen semua request miss serentak. Untuk mayoritas aplikasi Express, lock SET NX sudah cukup dan lebih mudah dipahami tim.
Jangan cache respons yang salah
Satu bug halus: meng-cache respons error atau data kosong seolah-olah valid. Kalau getProductsFromDb melempar error transient (misalnya timeout DB) dan Anda meng-cache hasil null, semua user akan melihat data kosong selama TTL berlaku.
Aturannya: hanya cache hasil sukses yang bermakna. Untuk hasil kosong yang legitimate (misalnya pencarian tanpa hasil), boleh di-cache tapi dengan TTL sangat pendek (negative caching) supaya tidak menghantam DB berulang, sekaligus tidak mengunci kekosongan terlalu lama.
const result = await fetcher();
if (result == null) {
// Negative cache singkat, bukan TTL penuh
await redis.set(key, JSON.stringify(null), 'EX', 10);
} else {
await redis.set(key, JSON.stringify(result), 'EX', ttlSeconds);
}
Pitfalls yang sering diabaikan
1. KEYS di production
Sudah disinggung, tapi ini penyebab insiden yang cukup sering: KEYS * atau KEYS prefix:* memblokir single-thread Redis sampai selesai men-scan seluruh keyspace. Di instance dengan jutaan key, ini bisa membekukan Redis beberapa ratus milidetik hingga detikan — dan semua command lain antre. Gunakan SCAN atau desain versioning key.
2. Serialisasi yang mahal
JSON.stringify/JSON.parse untuk objek besar bukan gratis. Kalau Anda meng-cache payload besar yang di-parse ribuan kali per detik, biaya CPU parsing bisa menghapus keuntungan cache. Ukur; kadang meng-cache di lapisan lain (in-process LRU untuk hot key) lebih murah untuk key yang sangat panas.
3. maxmemory-policy tidak diset
Ini pitfall production paling menyakitkan. Default beberapa setup Redis adalah noeviction — saat memory penuh, write akan gagal alih-alih meng-evict key lama. Untuk use case cache murni, set kebijakan eviction:
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
Dengan allkeys-lru, Redis membuang key yang paling jarang dipakai saat penuh, dan cache Anda tetap berfungsi. Pastikan juga kode Anda menganggap Redis sebagai cache yang bisa kehilangan data kapan saja — bukan penyimpanan permanen.
4. Cache key tanpa versi/namespace
Saat struktur data berubah (misalnya Anda menambah field baru ke objek produk), key lama masih berisi bentuk lama. Kode baru bisa error saat membaca bentuk lama. Sisipkan suffix versi (:v1, :v2) di key, dan naikkan versi saat skema payload berubah. Deploy baru = key lama otomatis di-abaikan.
5. Connection leak di serverless / worker
Kalau Anda deploy Express di lingkungan yang sering cold start (atau membuat client Redis di dalam handler), Anda bisa kehabisan koneksi. Buat satu client di module scope dan reuse. Untuk PM2 cluster atau multi-worker, setiap worker punya satu client — itu wajar dan aman.
6. Lupa timeout pada command
Sudah dibahas di setup, tapi layak diulang: tanpa commandTimeout, Redis yang lambat bikin request Express menggantung. Cache yang seharusnya mempercepat malah jadi titik kegagalan baru. Selalu set timeout dan selalu punya fallback ke sumber data.
Checklist sebelum deploy caching ke production
- Client Redis di-share, punya
errorhandler,commandTimeout, danretryStrategy. - Cache-aside dengan fallback: Redis down tidak menjatuhkan request, hanya melambatkannya.
- TTL sesuai toleransi staleness tiap jenis data, plus jitter untuk key yang expire barengan.
- Invalidation eksplisit saat write (delete key atau bump versi), dengan urutan write-DB-dulu-baru-invalidate.
- Tidak ada
KEYSdi jalur runtime; pakaiSCANatau versioning. - Proteksi stampede (
SET NXlock atau early refresh) untuk key populer. maxmemory-policydiset ke kebijakan eviction, bukannoeviction.- Monitoring hit ratio per-endpoint — kalau sebuah key hit ratio-nya rendah (misalnya di bawah 40%), evaluasi ulang apakah layak di-cache.
Penutup
Caching Redis di Express bukan soal menempel redis.get di depan setiap query. Yang membedakan cache yang aman di production dari yang bikin insiden adalah detail: fallback saat Redis mati, TTL yang mencerminkan toleransi staleness, invalidation yang tidak menciptakan race, proteksi stampede, dan kebijakan eviction yang benar.
Mulai dari cache-aside yang eksplisit dengan fallback, ukur hit ratio per-endpoint, dan hanya tambahkan kompleksitas (lock, versioning, in-process layer) saat data menunjukkan Anda memang membutuhkannya. Cache adalah optimasi — perlakukan sebagai sesuatu yang bisa hilang kapan saja, dan aplikasi Anda akan jauh lebih tahan banting.
Ditulis oleh Reza Pradipta