Ada bug yang bikin malu bukan karena susah diperbaiki, tapi karena siapa pun bisa melihatnya tanpa perlu buka DevTools. Klien saya melihatnya dari kursinya, dengan mata telanjang, lalu mengirim screenshot. Lalu mengirim lagi. Lalu mengirim lagi.
Isinya selalu sama: satu halaman, dua angka yang bertentangan.
Gejala: satu koin, dua harga, satu layar
Situs yang saya kerjakan adalah situs berita dan data pasar crypto di atas Next.js 16, dengan data harga dari API pihak ketiga dan cache di Upstash Redis. Di bagian paling atas ada ticker berjalan. Di halaman /markets ada tabel koin lengkap dengan harga, perubahan 24 jam, dan volume.
Ticker bilang BTC sekitar $77k. Tabel di halaman yang sama bilang sekitar $76k. Koin yang sama, detik yang sama, layar yang sama.
Selisih seribu dolar di situs data pasar itu bukan detail kosmetik. Itu langsung menghancurkan kepercayaan pada semua angka lain di halaman. Kalau dua komponen tidak bisa sepakat soal harga Bitcoin, kenapa pengunjung harus percaya pada kolom volume, atau pada daftar Top Movers di homepage?
Saya membakar dua hari untuk ini, dari 28 sampai 30 April. Dan bagian paling menyebalkannya: selama dua hari itu tidak ada satu pun error. Tidak ada log merah, tidak ada request gagal, tidak ada status selain 200. Semua komponen bekerja persis seperti yang ditulis.
Dua hari mencari di tempat yang salah
Karena tidak ada error, saya cuma punya teori. Dan teori pertama saya semuanya salah di lapisan yang sama: saya mengira ini bug tampilan.
Tebakan pertama, pembulatan. Mungkin ticker memformat dengan presisi berbeda dari tabel. Saya bandingkan formatternya. Sama.
Tebakan kedua, mata uang. Mungkin satu komponen minta harga dalam USD dan yang lain kebawa konversi. Saya cek parameternya. Dua-duanya USD.
Tebakan ketiga, cache CDN. Mungkin satu bagian halaman dilayani dari respons yang lebih lama. Saya kejar header, saya paksa hard reload, saya buka di jendela privat. Selisihnya tetap muncul, dan yang lebih membingungkan, angkanya berubah-ubah. Kadang selisihnya seribu dolar, kadang cuma dua ratus, kadang keduanya kebetulan cocok dan saya sempat mengira sudah beres.
Justru sifat "kadang cocok" itu yang seharusnya jadi petunjuk sejak awal. Bug format tidak pernah kebetulan benar. Bug waktu iya.
Akar masalah: tiga sumber kebenaran yang menyamar jadi satu
Waktu akhirnya saya berhenti melihat komponennya dan mulai melihat dari mana masing-masing komponen mengambil datanya, gambarnya langsung jelas. Setiap permukaan yang menampilkan harga punya jalur fetch-nya sendiri.
Ticker memanggil endpoint harga ringkas untuk daftar koin yang dia butuhkan. Tabel /markets memanggil endpoint market dengan per_page=50. Halaman pertama daftar koin memanggil endpoint market yang sama tapi dengan page=1&per_page=100. Tiga panggilan berbeda, dan yang paling penting, tiga cache key berbeda di Redis.
// Tiga permukaan, tiga entri cache yang benar-benar terpisah
cache:simple-price:bitcoin,ethereum,... // ticker
cache:coins-markets:per_page=50 // tabel /markets
cache:coins-markets:page=1&per_page=100 // daftar koinIni bukan salah ketik dan bukan konfigurasi yang lupa diseragamkan. Setiap key punya TTL yang sama persis. Dari daftar konstanta, semuanya terlihat rapi dan konsisten.
Masalahnya, TTL yang sama tidak berarti data yang sama.
TTL menentukan seberapa lama sebuah entri hidup, bukan kapan dia lahir. Key ticker mungkin diisi ulang pada detik ke-0, key tabel pada detik ke-9, key daftar koin pada detik ke-13, tergantung siapa yang kebetulan datang duluan setelah entrinya kedaluwarsa. Setelah itu masing-masing berjalan di fasenya sendiri dan tidak pernah bertemu lagi.
Jadi pada satu momen pengamatan, angka di ticker bisa hasil fetch yang baru saja terjadi, sementara angka di tabel hasil fetch yang hampir kedaluwarsa. Karena fase tiap key bergeser sendiri-sendiri, tergantung kapan pengunjung berikutnya datang, jarak umur antar-permukaan bisa melebar sampai hampir selebar satu jendela TTL penuh, belasan detik. Di pasar yang tenang, jeda selebar itu tidak terlihat. Di pasar yang sedang bergerak, jeda selebar itu cukup untuk jadi selisih seribu dolar, tayang di satu layar, dengan dua angka yang saling menyalahkan.
Selama dua hari saya mencari komponen mana yang salah. Tidak ada yang salah. Yang salah adalah asumsi bahwa mereka sedang melihat data yang sama, padahal secara arsitektur mereka memang tidak pernah begitu.
Perbaikan: satu key, banyak konsumen
Perbaikannya bukan menyamakan TTL, karena TTL-nya memang sudah sama. Perbaikannya adalah menghapus dua sumber kebenaran yang berlebih.
Saya bikin satu fetcher bersama yang mengambil 100 koin teratas berdasarkan kapitalisasi pasar, disimpan di bawah satu key Redis:
const KEY = "coingecko:top-coins:v1";
const TTL_SECONDS = 15;
export async function fetchTopCoinsTier1() {
const cached = await redis.get(KEY);
if (cached) return cached;
const fresh = await fetchMarkets({ perPage: 100, order: "market_cap_desc" });
await redis.set(KEY, fresh, { ex: TTL_SECONDS });
return fresh;
}Lalu semua permukaan yang tadinya punya jalur sendiri diarahkan ke fetcher itu, dan memotong bagian yang mereka butuhkan dari hasil yang sama:
const coins = await fetchTopCoinsTier1();
// ticker: ambil beberapa koin utama
const tickerCoins = coins.slice(0, 10);
// tabel /markets: 50 teratas
const marketRows = coins.slice(0, 50);
// Top Movers di homepage: urutkan ulang dari array yang sama
const topMovers = [...coins]
.sort((a, b) => b.price_change_percentage_24h - a.price_change_percentage_24h)
.slice(0, 5);Poin pentingnya ada di kata slice. Pemotongan dan pengurutan pindah ke sisi konsumen, bukan ke parameter request. Begitu per_page dan page tidak lagi ikut membentuk cache key, tidak ada lagi cara bagi dua permukaan untuk mendapat data dari fetch yang berbeda. Mereka membaca objek yang sama persis, dari entri Redis yang sama persis, dengan usia yang sama persis.
Selisihnya hilang total, bukan mengecil. Itu bedanya memperbaiki penyebab dengan mengurangi gejala.
Pola yang sama saya terapkan untuk data koleksi NFT lewat fetchTopNftCollections(), karena masalahnya identik dan tinggal menunggu waktu untuk muncul di sana.
Bonus yang tidak saya rencanakan: tiga jalur fetch yang menyusut jadi satu juga berarti panggilan ke API pihak ketiga ikut turun drastis. Tapi itu efek samping, bukan tujuannya. Tujuannya adalah supaya halaman berhenti membantah dirinya sendiri.
Pelajaran
- Cache key itu batas konsistensi. Dua permukaan yang membaca key berbeda tidak akan pernah dijamin sepakat, seberapa pun rapi TTL-nya.
- TTL mengatur umur, bukan fase. Entri yang lahir di waktu berbeda akan tetap kedaluwarsa di waktu berbeda, selamanya.
- Kalau selisihnya berubah-ubah dan kadang kebetulan hilang, berhenti mencari bug format. Itu bug waktu.
- Parameter request seperti
pagedanper_pagediam-diam ikut membentuk cache key. Kalau data dasarnya sama, ambil supersetnya sekali lalu potong di sisi konsumen. - Bug yang bisa dilihat klien tanpa buka DevTools selalu lebih mahal dari yang terlihat. Satu screenshot cukup untuk membuat semua angka lain di halaman ikut diragukan.