Ada satu momen dalam debugging yang rasanya paling tidak enak: kamu sudah memperbaiki penyebab yang paling jelas, dan angkanya tetap tidak bergerak.
Di sebuah situs klien yang jalan di atas Next.js dan Sanity, saya baru saja menambal salah konfigurasi cache yang memang keliru, sebuah route yang dipaksa dinamis padahal tidak perlu sehingga tiap request menembus langsung ke API. Perbaikan itu benar dan tetap perlu, tapi kuotanya tetap terbakar di kisaran 150 ribu request per hari. Tulisan ini tentang apa yang terjadi setelah perbaikan yang "seharusnya cukup" ternyata tidak cukup.
Teori yang paling enak dipercaya
Tersangka favorit semua orang di kasus seperti ini adalah bot. Situsnya punya ribuan halaman detail, sitemap-nya besar, dan Googlebot memang rajin. Narasinya rapi: crawler menyapu ribuan slug, tiap slug memicu beberapa fetch, kuota habis. Saya sempat yakin sekali dengan teori ini. Rencana turunannya bahkan sudah saya susun: batasi crawl rate, tunda indexing sebagian route, bikin robots.txt yang lebih galak.
Untungnya eksekusi rencana itu saya tunda sehari, karena ada satu pertanyaan yang mengganjal. Kalau memang bot, request-nya mestinya datang dari banyak IP dan banyak user agent. Saya tidak punya data apa pun untuk membuktikan atau membantah itu. Yang saya punya cuma angka total di dashboard: besar, dan sama sekali tidak menjelaskan apa-apa.
Nyalakan log delivery dulu, baru berteori
Sanity punya fitur yang jarang disebut orang: pengiriman log request. Di dashboard project ada bagian "Configure log delivery", tinggal dinyalakan dan diarahkan ke bucket tujuan. Setelah aktif, tiap request ke API dan CDN dicatat sebagai satu baris NDJSON lengkap dengan atributnya, termasuk identitas query GROQ yang dipakai.
Saya nyalakan, saya tunggu sampai drop pertama masuk keesokan harinya, lalu saya tarik seluruh berkas yang tersedia. Dari titik itu pekerjaannya berubah dari menebak jadi menghitung. Skrip agregatornya sependek ini:
import fs from "node:fs";
const rows = fs.readFileSync("sanity-logs.ndjson", "utf8").split("\n");
const byQuery = new Map();
let total = 0;
for (const line of rows) {
if (!line.trim()) continue;
const rec = JSON.parse(line);
const id = rec.attributes?.["sanity.groqQueryIdentifier"] ?? "unknown";
byQuery.set(id, (byQuery.get(id) ?? 0) + 1);
total++;
}
[...byQuery.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.forEach(([id, n]) =>
console.log(n.toString().padStart(8), ((n / total) * 100).toFixed(1) + "%", id)
);Kuncinya ada di sanity.groqQueryIdentifier. Atribut itu memberi tiap query bentuk identitas yang stabil, jadi ribuan request yang cuma beda parameter tetap terkumpul jadi satu baris ringkasan. Tanpa itu kamu cuma melihat jutaan URL yang mirip-mirip dan tidak bisa menyimpulkan apa-apa.
Angka yang mematahkan teori saya
Totalnya 1,06 juta request selama delapan hari. Hasil agregasinya bikin saya diam sebentar.
- Lima query teratas menyumbang 85% dari seluruh burn.
- Empat dari lima itu adalah singleton level layout:
apiSettings,navConfig,globalConfig, dan satu lagi list singleton yang dirender di footer. Masing-masing 16 sampai 21%. - 93% request datang dengan user agent Node dan berasal dari satu IP Railway. Itu server kami sendiri, bukan pengunjung, bukan crawler.
- Kontribusi pola crawl yang saya tuduh sejak awal: sekitar 14%. Angka ini tidak bertabrakan dengan yang di atas: crawler memang tidak memanggil Sanity langsung, mereka cuma memicu render, dan server kami yang lalu menembak query-nya.
Teori bot saya bukan salah total, dia cuma kecil. Kalau saya jalan dengan intuisi, saya akan menghabiskan sehari penuh mengatur crawl budget demi memangkas 14%, sementara 85% penyebab yang sebenarnya tidak tersentuh sama sekali.
Akar masalahnya: singleton yang polos
Empat dokumen itu isinya konfigurasi. Nyaris tidak pernah berubah, mungkin sekali seminggu. Tapi mereka di-fetch oleh layout, header, dan footer, artinya tiap render halaman apa pun menariknya lagi.
Dan di sinilah kesalahan saya yang sesungguhnya: semua getter itu cuma menumpang TTL baseline lima menit. Lima menit terdengar aman sampai kamu kalikan dengan jumlah render. Ribuan halaman dirender ulang sepanjang hari, tiap render memicu empat fetch config, dan tiap jam jendela cache-nya dibuka ulang dua belas kali. Angkanya membesar bukan karena traffic-nya besar, tapi karena perkalian.
Perbaikannya: TTL panjang per singleton, dipasangkan dengan tag
Solusinya bukan menaikkan baseline global. Itu bakal ikut membuat konten yang memang perlu segar jadi basi. Yang saya lakukan adalah menetapkan TTL eksplisit per getter, lalu memasangkannya dengan invalidasi berbasis tag supaya editor tetap melihat perubahannya seketika.
export const getNavConfig = () =>
sanityFetch<NavConfig>(NAV_CONFIG_QUERY, {}, {
revalidate: 3600,
tags: ["navConfig"],
});Semua config singleton dapat revalidate: 3600. Satu getter yang menyuplai widget dengan data lebih cepat berubah dapat 1800. Sisanya saya petakan satu per satu sampai jadi matriks 18 getter, masing-masing dengan alasan tertulis kenapa angkanya segitu.
Bagian tag itu yang bikin TTL satu jam tidak menakutkan. Webhook dari CMS memanggil invalidasi berdasarkan tipe dokumen yang barusan diedit:
export async function POST(req: Request) {
const body = await req.json();
revalidateTag(body._type);
return Response.json({ revalidated: true });
}Jadi TTL panjang mengurus kondisi normal, dan tag mengurus kondisi "editor barusan ganti nomor telepon di footer dan mau lihat hasilnya sekarang juga".
Hasilnya: dari sekitar 150 ribu request per hari turun ke 13 sampai 15 ribu per hari. Sepuluh kali lipat, tanpa menyentuh satu baris pun urusan crawler.
Yang saya bawa pulang
- Perbaikan pertama yang benar tidak otomatis berarti perbaikan itu cukup. Cek ulang angkanya setelah deploy, bukan cek perasaanmu.
- Sebelum mengoptimasi, ukur dulu. Sehari menunggu log jauh lebih murah daripada seminggu menebak.
- Agregasi per identitas query, bukan per URL. Ribuan URL yang mirip menyembunyikan lima query yang sebenarnya bertanggung jawab.
- Kalau mayoritas request datang dari user agent server sendiri, masalahnya ada di pola fetch-mu, bukan di pengunjungmu.
- Data yang jarang berubah tapi dibaca tiap render adalah kandidat TTL terpanjang di sistem. Pasangkan dengan tag invalidation supaya panjang tidak berarti basi.
