Sebuah situs klien berbasis Next.js 16 punya halaman detail koin di route /coins/[slug]. Homepage mulus, tidak ada keluhan. Tapi begitu saya klik salah satu koin dan berpindah ke /coins/ethereum, browser cuma menampilkan pesan generik: "This page couldn't load". Saya coba di Brave, sama. Saya coba di Chrome, sama persis. Reload keras kadang tembus, tapi navigasi antar halaman langsung tumbang.
Refleks pertama saya: cek dari terminal. Saya curl URL yang sama persis, dan dapat 200. Header bersih, body penuh, semua markup ada di tempatnya. Di mata terminal, halaman itu sehat walafiat. Jadi saya sempat menyimpulkan yang salah: "server oke, ini pasti bug di sisi klien." Persis kebalikan dari kasus lama di mana curl balik kosong padahal browser tampil penuh. Kali ini curl yang memberi saya rasa aman palsu.
Kenapa curl bilang 200 padahal browser gagal
Kuncinya ada pada bagaimana Next.js melayani dua jenis request berbeda untuk route yang sama. curl polos meminta dokumen HTML penuh. Ketika render server bermasalah di jalur ini, Next sering tetap membalas 200 dengan HTML dari error boundary, jadi status kodenya menipu. Sementara itu, navigasi antar halaman di browser tidak mengambil HTML penuh. Ia melakukan fetch RSC, yaitu request dengan header RSC: 1, dan aliran RSC jauh lebih ketat. Kalau render melempar, stream itu balik 500 apa adanya.
Jadi saya ulangi probe-nya, kali ini menyamai apa yang browser lakukan:
curl -I https://situs-klien.example/coins/ethereum
# HTTP/2 200
curl -I -H "RSC: 1" https://situs-klien.example/coins/ethereum
# HTTP/2 500Di sana bug-nya keluar dari persembunyian. Request biasa 200, request RSC 500. Di dalam stream RSC terselip digest: '1424695691', tapi digest saja tidak memberi tahu apa-apa selain "ada error yang di-obfuscate di produksi". Saya butuh stderr aslinya.
Menangkap error yang sebenarnya
Digest produksi itu memang sengaja buram. Untuk membaca pesan asli, saya reproduksi kondisi build di lokal, bukan menebak dari pnpm dev:
pnpm build && pnpm startBegitu server produksi jalan di mesin saya dan saya buka /coins/ethereum, stderr langsung memuntahkan dua hal yang berbeda. Yang pertama sebuah peringatan sangat spesifik dari Next:
Route /coins/[slug] used "revalidateTag apiSettings" during render which is unsupported.Yang kedua, terselip di antara log lain, sebuah error dari lapisan cache:
ERR max requests limit exceededDua sinyal, dua bug yang kebetulan menumpuk di minggu yang sama.
Dua bug yang menumpuk
Bug pertama ada di fetcher data. Setiap kali sebuah provider API dipanggil, ada fungsi markProviderSuccess dan markProviderError yang mencatat status provider, dan di dalam keduanya ada panggilan ini:
function markProviderSuccess() {
// dipanggil dari dalam fetcher, saat render berlangsung
revalidateTag("apiSettings", "max");
}Di Next.js 16, memanggil revalidateTag saat render dilarang. revalidateTag itu operasi mutasi cache, tempatnya di Server Action atau Route Handler, bukan di tengah render komponen server. Karena fetcher ini dipanggil saat merender /coins/[slug], Next menolaknya dan render gagal.
Bug kedua memperparah keadaan. Lapisan cache situs ini pakai Upstash Redis di free tier, 500 ribu request per bulan. Minggu itu kuotanya habis, dan setiap panggilan redis.get, redis.set, dan redis.incr mulai melempar ERR max requests limit exceeded. Error itu tidak ditangkap, jadi ia menjalar tanpa penghalang sampai ke dalam stream RSC dan ikut menjatuhkan render.
Gabungan keduanya menjelaskan kenapa curl biasa masih 200: respons HTML penuh menyembunyikan error render di balik status 200, sementara respons RSC menegakkan aturan dan membalas 500.
Perbaikannya
Tiga langkah, sesuai urutan penyebabnya.
Pertama, keluarkan revalidateTag dari jalur render. Penandaan sukses atau gagal provider tidak perlu mem-purge cache saat render sedang berlangsung. Saya pindahkan invalidasi itu ke Route Handler yang memang boleh memutasi cache, dan fetcher-nya cukup membaca cache tanpa menulis balik:
function markProviderSuccess() {
// tidak ada revalidateTag di sini lagi
metrics.record("provider_ok");
}Kedua, bungkus setiap sentuhan Redis supaya kegagalan cache jatuh mulus jadi cache-miss, bukan menjatuhkan halaman:
async function safeGet(key: string) {
try {
return await redis.get(key);
} catch {
return null; // anggap cache miss, biarkan render lanjut ke sumber asli
}
}Pola yang sama untuk set dan incr: coba, tangkap, dan kalau gagal telan diam-diam alih-alih membiarkan error naik ke stream. Cache itu optimasi, bukan syarat hidup halaman.
Ketiga, naikkan kuota Upstash supaya lapisan cache tidak mentok lagi di beban normal. Tapi itu langkah higienis, bukan perbaikan inti. Perbaikan intinya adalah dua yang pertama: render tidak lagi mencoba memutasi cache, dan cache yang mati tidak lagi bisa menjatuhkan render.
Checklist
- Kalau
curlbalik200tapi browser gagal berpindah halaman, jangan percaya200itu. Probe ulang dengan header RSC:curl -H "RSC: 1". - Status
200di respons HTML penuh bisa menyembunyikan crash render. Stream RSC lebih ketat dan akan menunjukkan500yang sebenarnya. - Jangan pernah panggil
revalidateTagsaat render. Tempatnya di Server Action atau Route Handler, bukan di dalam fetcher komponen server. - Bungkus panggilan cache (
get,set,incr) dengan try/catch yang jatuh ke cache-miss. Cache mati harusnya memperlambat, bukan mematikan. - Reproduksi bug khusus produksi dengan
pnpm build && pnpm start, jangan andalkanpnpm devyang terlalu permisif. - Digest error produksi sengaja dibuat buram. Baca stderr asli untuk pesan yang bermakna.
