Implementasi Server-Side Caching dengan Redis di Node.js: Menurunkan Latency API Secara Drastis
Mengukur Latency API dan Mengidentifikasi Sumber Keterlambatan
Sebelum menambahkan caching, kita harus tahu seberapa lambat API yang sedang berjalan. Latency tinggi biasanya berasal dari tiga komponen: network round-trip, waktu proses di server, dan waktu akses database. Ketika endpoint menerima request berulang dengan response yang identik, akses database menjadi komponen yang paling banyak membuang waktu.
Network round-trip bergantung pada jarak dan kondisi jaringan antara client dan server, sehingga di luar kendali kita. Waktu proses di server mencakup parsing request, validasi, dan serialisasi response — biasanya kecil jika aplikasi ditulis dengan efisien. Komponen yang paling sering menjadi biang keterlambatan adalah akses database, terutama ketika query melibatkan join antar tabel atau aggregasi pada dataset besar.
Cara paling sederhana untuk mengukur latency adalah dengan middleware yang mencatat durasi setiap request. Kita pasang middleware ini sebelum semua route sehingga setiap request tercatat waktu eksekusinya di terminal.
import express from 'express';
const app = express();
app.use(express.json());
app.use((req, res, next) => {
const start = process.hrtime.bigint();
res.on('finish', () => {
const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
console.log(`${req.method} ${req.originalUrl} -> ${durationMs.toFixed(2)} ms`);
});
next();
});
app.get('/api/products', async (req, res) => {
const products = await db.product.findMany();
res.json({ data: products });
});
app.listen(3000);Log yang dihasilkan memberi kita baseline sebelum optimasi. Jika endpoint /api/products konsisten memakan waktu 150 ms untuk response yang sama, kita punya angka pembanding yang jelas setelah caching diterapkan. Data dengan frekuensi akses tinggi dan frekuensi perubahan rendah adalah kandidat utama caching, bukan data yang berubah setiap detik.
Memahami Cara Kerja Server-Side Caching dengan Redis
Redis adalah in-memory key-value store yang menyimpan data di RAM. Akses ke RAM jauh lebih cepat daripada query ke database yang harus membaca dari disk melalui proses I/O. Perbandingan tipikal: query database rata-rata memakan 10–50 ms, sedangkan operasi baca Redis hanya 0.5–2 ms. Untuk endpoint yang menerima ribuan request per detik, penghematan puluhan milidetik per request menjadi signifikan secara kumulatif.
Pola yang paling umum digunakan adalah Cache-Aside, atau lazy loading. Aplikasi mengecek cache terlebih dahulu. Jika data ditemukan (cache hit), response langsung dikembalikan tanpa menyentuh database. Jika tidak ditemukan (cache miss), aplikasi mengambil data dari database, menyimpannya ke Redis, lalu mengembalikannya ke client. Nama lazy loading merujuk pada fakta bahwa cache hanya diisi saat data benar-benar diminta, bukan diisi lebih awal secara proaktif.
Setiap data di Redis memiliki TTL (Time-To-Live) yang menentukan masa berlaku data di memori — analogi sederhananya seperti tanggal kedaluwarsa pada produk kemasan. Setelah TTL habis, Redis menghapus key secara otomatis sehingga data basi tidak tersaji selamanya dan memori tetap terkendali. Nilai TTL yang tepat bergantung pada seberapa sering data berubah dan seberapa besar toleransi aplikasi terhadap data basi.
Struktur data yang umum dipakai adalah string yang berisi JSON serialized, dengan key naming convention seperti cache:products atau cache:products:123. Konvensi ini penting karena memudahkan proses invalidation nanti. Data yang tersimpan bisa berupa response lengkap, hasil aggregasi, atau potongan data yang mahal untuk dihitung ulang. Hindari menyimpan data yang unik per user di cache global karena nilai reuse-nya rendah dan justru menghabiskan memori.
Menerapkan Cache-Aside Pattern dengan Redis dan Express
Kita mulai dengan menghubungkan aplikasi Express ke Redis menggunakan library ioredis. Library ini menyediakan API berbasis Promise dan menangani reconnect secara otomatis jika koneksi terputus. Konfigurasi retryStrategy memberi jeda yang semakin panjang setiap kali percobaan reconnect gagal, hingga batas maksimal 3 detik.
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
retryStrategy: (times) => Math.min(times * 100, 3000)
});
redis.on('error', (err) => {
console.error('Redis connection error:', err);
});
export default redis;Inti dari pola cache-aside kita bungkus dalam helper getOrSet. Helper ini menerima key, fungsi pengambil data dari database, dan nilai TTL. Alur kerja helper ini: cek key di Redis, jika ada parse dan kembalikan, jika tidak panggil fetchFn, simpan hasilnya ke Redis, lalu kembalikan.
import redis from './redis.js';
export async function getOrSet(key, fetchFn, ttl = 60) {
const cached = await redis.get(key);
if (cached !== null) {
return JSON.parse(cached);
}
const data = await fetchFn();
await redis.set(key, JSON.stringify(data), 'EX', ttl);
return data;
}Helper ini kita pakai di route handler. Endpoint /api/products/:id yang tadinya membaca database setiap request kini hanya membaca database sekali per periode TTL.

Gambar: Alur cache-aside saat cache miss — aplikasi mengecek Redis, query database jika data tidak ditemukan, lalu menyimpan hasilnya kembali ke Redis — Sumber: Redis Docs
app.get('/api/products/:id', async (req, res) => {
const { id } = req.params;
const product = await getOrSet(
`cache:products:${id}`,
async () => db.product.findUnique({ where: { id } }),
300
);
res.json({ data: product });
});Request pertama akan menghasilkan cache miss dan memakan waktu seperti baseline awal. Request berikutnya dalam rentang 5 menit akan menjadi cache hit dengan durasi turun drastis, biasanya ke kisaran 2–5 ms. Perbedaan ini yang kita lihat di log middleware pengukur latency sebagai bukti keberhasilan optimasi.
Satu hal yang perlu diperhatikan adalah konsistensi bentuk data. Redis menyimpan segala sesuatu sebagai string, sehingga objek yang kita simpan harus diserialisasi dengan JSON.stringify dan di-parse kembali dengan JSON.parse saat dibaca. Helper getOrSet menangani dua langkah ini secara internal, sehingga route handler tetap bersih dan fokus pada logika bisnis.
Pola ini juga memberi kita keuntungan tambahan berupa fail-safe. Jika fetchFn melempar error karena database sedang bermasalah, helper akan meneruskan error tersebut ke route handler tanpa menyimpan nilai yang salah ke cache. Dengan begitu cache tidak pernah terisi data yang tidak valid, dan aplikasi tetap bisa menampilkan error yang benar kepada client.
Menangani Cache Invalidation agar Data Selalu Konsisten
Cache yang tidak pernah diinvalidasi akan menyajikan data basi. Saat database diupdate melalui operasi create, update, atau delete, kita harus menghapus key cache yang terkait agar request berikutnya mengambil data baru.
app.post('/api/products', async (req, res) => {
const product = await db.product.create({ data: req.body });
await redis.del('cache:products');
await redis.del(`cache:products:${product.id}`);
res.status(201).json({ data: product });
});Setelah operasi write berhasil, kita memanggil redis.del untuk menghapus key daftar produk dan key produk individual. Request berikutnya akan mendapati cache miss, mengambil data terbaru dari database, dan mengisi ulang cache. Pola ini disebut explicit deletion karena kita mengontrol waktu invalidation secara eksplisit.
Trade-off yang perlu dipertimbangkan adalah panjang TTL. TTL pendek membuat data lebih segar tetapi cache hit rate rendah karena data cepat kedaluwarsa. TTL panjang meningkatkan hit rate tetapi memperbesar risiko data basi. Data master seperti daftar kategori cocok dengan TTL 1 jam, sedangkan data transaksional seperti stok produk sebaiknya memakai TTL 30–60 detik atau explicit deletion.
Kesalahan umum yang sering terjadi adalah menghapus cache terlalu banyak atau terlalu sedikit. Menghapus key yang tidak terkait dengan operasi write hanya membuang hit rate tanpa manfaat. Sebaliknya, melewatkan key yang relevan membuat data basi tersaji lebih lama. Karena itu, key naming convention yang konsisten sangat menentukan keberhasilan strategi invalidation ini.
Pola write-through (memperbarui cache bersamaan dengan database) menjaga sinkronisasi sempurna tetapi menambah latency pada operasi write. Untuk sebagian besar kasus, kombinasi cache-aside dengan explicit deletion dan TTL yang sesuai sudah cukup.
Memantau Efektivitas Cache dan Menghindari Cache Stampede
Caching tidak berhenti setelah implementasi. Kita perlu memantau cache hit rate, yaitu persentase request yang dilayani dari cache tanpa menyentuh database. Hit rate di atas 90% menandakan strategi caching yang sehat, sedangkan di bawah 70% berarti pemilihan key atau TTL perlu dievaluasi ulang. Hit rate yang rendah menunjukkan bahwa sebagian besar request masih menembus database, sehingga manfaat caching belum terasa.
Redis menyediakan metrik ini melalui command INFO stats. Kita bisa melihat keyspace_hits dan keyspace_misses, lalu menghitung hit rate dengan rumus hits dibagi total request. Jalankan redis-cli INFO stats untuk membaca data ini secara real-time. Untuk pemantauan jangka panjang, integrasikan metrik ini ke dashboard monitoring seperti Grafana agar tren hit rate terlihat dari waktu ke waktu.
redis-cli INFO stats | grep keyspaceSatu masalah yang sering muncul adalah cache stampede. Fenomena ini terjadi ketika TTL dari banyak key habis pada waktu yang bersamaan, sehingga banyak request menembus langsung ke database secara serentak dan menyebabkan lonjakan beban. Mitigasi paling sederhana adalah menambahkan jitter, yaitu variasi acak kecil pada nilai TTL, agar waktu expiry tersebar dan tidak berbarengan.

Gambar: Perbandingan tanpa early recompute (banyak request miss bersamaan dan menyerbu database) versus dengan probabilistic early recompute (satu request memperbarui cache sebelum expiry) — Sumber: Cache Invalidation
function withJitter(ttl, variance = 0.1) {
const offset = ttl * variance * (Math.random() * 2 - 1);
return Math.round(ttl + offset);
}Output:
Simulasi TTL 300 detik dengan jitter 10% (10 percobaan):
Percobaan 01: 292 detik
Percobaan 02: 279 detik
Percobaan 03: 325 detik
Percobaan 04: 320 detik
Percobaan 05: 303 detik
Percobaan 06: 284 detik
Percobaan 07: 275 detik
Percobaan 08: 320 detik
Percobaan 09: 280 detik
Percobaan 10: 272 detikPendekatan lain adalah stale-while-revalidate, yang menyajikan data lama sambil memperbarui cache di background, atau single-flight request yang menggabungkan request identik yang datang bersamaan menjadi satu query database. Caching bukan solusi universal — data yang sangat dinamis seperti saldo akun atau data yang unik per user sebaiknya tidak di-cache karena hit rate-nya rendah dan risikonya tinggi.
Keputusan paling penting dalam strategi caching adalah memilih data mana yang layak di-cache. Data yang diakses ribuan kali dengan isi yang sama, seperti daftar produk, konfigurasi aplikasi, atau hasil aggregasi laporan, memberi manfaat terbesar. Sebaliknya, data yang berubah setiap detik atau sangat personal, seperti notifikasi real-time atau keranjang belanja per user, lebih baik dilayani langsung dari database. Mulai dari satu endpoint yang paling sering diakses, ukur baseline-nya, lalu perluas ke endpoint lain setelah melihat hasilnya.
Implementasi caching dengan Redis menuntut tiga langkah: ukur baseline latency, terapkan pola cache-aside, lalu pantau hit rate dan jaga konsistensi data. Mulailah dari endpoint yang paling sering diakses dan ukur perbedaannya dengan middleware timing yang sudah kita buat. Untuk pendalaman arsitektur backend dan optimasi performa API di aplikasi production, bergabunglah dengan kelas Web Development Rumah Coding.