Mengoptimalkan performa API Express: caching respons dengan Redis, HTTP caching headers dan ETag bawaan, kompresi gzip, serta pengukuran dan tuning endpoint yang lambat.

Setelah melindungi aplikasi dari serangan, episode 21 membuatnya cepat. Caching adalah teknik optimasi paling efektif yang dimiliki server: data yang sama tidak perlu dihitung atau diambil dari database berulang kali — cukup sekali, lalu disajikan dari memori.
Mengapa caching di episode ini penting untuk produksi? Karena latensi dan beban database adalah dua masalah paling umum saat aplikasi mulai ramai. Redis cache memotong beban database, ETag memotong bandwidth, dan gzip memotong ukuran transfer. Ketiganya bekerja di lapisan yang berbeda — dan kalian akan memahami kapan harus memakai yang mana.
Redis sudah kita kenalkan di episode 20 (rate limit store). Sekarang ia menjadi cache data:
import { Redis } from "ioredis"
import { config } from "../config.js"
export const cache = new Redis(config.redisUrl)
export async function getCached(key) {
const value = await cache.get(key)
return value ? JSON.parse(value) : null
}
export async function setCached(key, data, ttlSeconds) {
await cache.set(key, JSON.stringify(data), "EX", ttlSeconds)
}
export async function invalidateKey(key) {
await cache.del(key)
}JSON.stringify/JSON.parse karena Redis menyimpan string — objek dikonversi eksplisit."EX") membuat cache kadaluwarsa otomatis — mencegah data basi menumpuk selamanya.Pola cache-aside: baca cache dulu; jika kosong, ambil dari database lalu simpan:
import { getCached, setCached, invalidateKey } from "../utils/cache.js"
const LIST_CACHE_KEY = "users:list"
const TTL = 60
export const listUsers = async (req, res) => {
const cached = await getCached(LIST_CACHE_KEY)
if (cached) {
return res.json({ data: cached, cached: true })
}
const users = await User.find({}).lean()
await setCached(LIST_CACHE_KEY, users, TTL)
res.json({ data: users, cached: false })
}Beban database turun drastis: alih-alih query di setiap request, query berjalan paling banyak sekali per menit. Trade-offnya adalah stale data — cache 60 detik berarti data bisa tertinggal sampai 60 detik.
Cache hanya berguna jika di-invalidasi saat data berubah. Setiap operasi tulis menghapus cache terkait:
export const createUser = async (req, res) => {
const user = await User.create(req.body)
await invalidateKey(LIST_CACHE_KEY)
res.status(201).json({ data: user })
}
export const updateUser = async (req, res) => {
const user = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
)
await invalidateKey(LIST_CACHE_KEY)
await invalidateKey(`users:${user.id}`)
res.json({ data: user })
}Important
Aturan emas cache-aside: setiap write meng-invalidate cache yang terkait. Melupakan invalidasi membuat aplikasi menyajikan data basi — bug yang licin karena tidak melempar error, hanya berperilaku aneh. Asumsikan selalu ada pola get-cache + invalidate dalam satu pasang.
Express mengirim ETag (fingerprint konten) secara otomatis. Saat client mengirim If-None-Match, server mengembalikan 304 Not Modified tanpa body — hemat bandwidth:
app.set("etag", "strong")"strong" menghasilkan ETag per-byte (akurat untuk range request); "weak" per-konten. Default "weak" cukup untuk sebagian besar kasus.
Untuk aset statis yang jarang berubah (episode 11), set header cache agresif:
app.use(
"/static",
express.static("public", {
setHeaders: (res) => {
res.setHeader("Cache-Control", "public, max-age=86400")
},
})
)max-age=86400 memberitahu browser menyimpan aset satu hari. Kombinasikan dengan penamaan file ber-hash (CSS/JS yang diberi hash pada nama) agar perubahan versi tidak tertelan cache lama.
API JSON yang bergantung pada data pengguna biasanya memakai no-store — jangan disimpan:
res.setHeader("Cache-Control", "no-store")Respons privat (profil, data akun) tidak boleh di-cache bersama. Pilihan yang benar:
| Konten | Cache-Control |
|---|---|
| Aset publik (CSS/JS/gambar) | public, max-age=86400 |
| Data publik yang jarang berubah | public, max-age=300 |
| Data per-user / privat | no-store |
Body JSON besar bisa dikompresi hingga ~80%:
npm install compressionimport compression from "compression"
app.use(compression())Respons yang sudah kecil (kurang dari ~1 KB) justru lebih lambat jika dikompresi (overhead CPU + header). compression punya threshold otomatis, dan aset gambar/video (sudah terkompresi) sebaiknya di-skip. Verifikasi kompresi bekerja:
curl -s -H "Accept-Encoding: gzip" \
-D - http://localhost:3000/users -o /dev/nullHeader Content-Encoding: gzip menandakan kompresi aktif. Sebagai catatan, di produksi gzip/br biasanya ditangani reverse proxy (episode 24) — middleware ini tetap berguna untuk kasus tanpa proxy.
export const latencyTimer = (req, res, next) => {
const started = process.hrtime.bigint()
res.on("finish", () => {
const latencyMs = Number(process.hrtime.bigint() - started) / 1e6
logger.info({ path: req.path, latencyMs, status: res.statusCode }, "request")
})
next()
}Pola ini mengisi log structured episode 17 dengan data latensi per route — sumber kebenaran untuk menemukan endpoint yang perlu di-cache atau di-tuning.
Query find yang sering dan difilter butuh index:
userSchema.index({ email: 1 }, { unique: true })
userSchema.index({ createdAt: -1 })Tanpa index, MongoDB melakukan collection scan — lambat pada data besar. Ini optimasi database yang melengkapi cache aplikasi.
Tip
Ukur sebelum dan sesudah setiap optimasi dengan autocannon atau k6 (load test formal kita bahas di episode 25). Optimasi tanpa pengukuran hanyalah tebakan — angka latensi dan throughput adalah hakim yang adil.
Data basi selamanya — pastikan setiap write meng-invalidate. Pola cache-aside yang disiplin menghindari ini.
Cache 1 jam untuk daftar stok yang berubah per detik adalah bencana. TTL mengikuti frekuensi perubahan data.
Data per-user yang di-cache tanpa key per-user akan bocor ke user lain. Jika cache, sertakan identitas user di key (users:42:profile).
Redis cepat karena data di RAM — tetapi bukan pengganti database. Simpan yang sering dibaca dan jarang berubah; bukan seluruh state.
Note
Urutan pilihan cache di sistem produksi: CDN (paling luar) → HTTP cache browser (ETag/Cache-Control) → cache aplikasi Redis (paling dalam, paling dekat logika). Episode ini membangun lapisan dalam; lapisan luar kita sentuh lagi saat deployment di episode 24.
Episode 21 mengoptimalkan performa: cache-aside Redis dengan invalidasi disiplin, HTTP caching headers dan ETag bawaan Express, kompresi gzip, serta pengukuran latensi yang memandu keputusan optimasi.
Inti yang harus dibawa pulang:
Cache-Control memangkas transfer; pilih header sesuai jenis konten.compression() untuk gzip; lewati aset yang sudah terkompresi.Di episode 22 selanjutnya kita akan membangun background jobs & queue — BullMQ dengan Redis, worker process, cron, dan pemindahan pekerjaan berat keluar dari request handler. Sampai jumpa di episode 22!