Masalah data stale setelah mutation di Next.js App Router sering tampak seperti cache yang “bandel”, padahal penyebabnya bisa lebih halus: race condition antara proses tulis ke database, pemanggilan revalidateTag, dan request lain yang datang hampir bersamaan. Gejalanya biasanya hanya muncul di production, sulit direproduksi di local, dan sesekali membuat user melihat data lama beberapa detik setelah update berhasil.
Artikel ini membahas debug race condition revalidateTag dan data stale dari sisi backend, bukan UI. Fokusnya adalah alur request, cache fetch berbasis tag, route handler atau server action yang melakukan mutation, strategi logging, metrik yang perlu dicek, dan pola perbaikan yang aman agar bug tidak muncul lagi.
Studi kasus: gejala di production
Konteksnya sederhana. Kita punya halaman detail produk di App Router. Data dibaca lewat fetch server-side dengan tag cache, misalnya product:{id}. Ketika admin mengubah harga atau stok, backend memanggil revalidateTag agar request berikutnya membaca data terbaru.
Namun di production muncul pola berikut:
- Mutation API mengembalikan status sukses.
- Log menunjukkan
revalidateTagmemang terpanggil. - Beberapa request setelah mutation masih menerima data lama.
- Bug lebih sering terjadi saat traffic tinggi atau ada beberapa request paralel ke resource yang sama.
- Local development hampir tidak pernah menunjukkan masalah yang sama.
Ini tanda kuat bahwa masalahnya bukan sekadar “lupa invalidasi cache”, tetapi ada urutan kejadian yang tidak konsisten antar request.
Arsitektur minimal yang memicu masalah
Pola baca dengan fetch cache dan tag
Contoh pola baca di server component atau helper backend:
export async function getProduct(id: string) {
const res = await fetch(`${process.env.API_BASE_URL}/internal/products/${id}`, {
next: {
tags: [`product:${id}`],
revalidate: 3600,
},
});
if (!res.ok) {
throw new Error('Failed to fetch product');
}
return res.json();
}Pola ini valid: data produk diberi tag spesifik, lalu mutation akan menginvalidasi tag tersebut.
Pola mutation yang tampak benar, tetapi rawan race condition
Misalnya route handler atau server action berikut:
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
export async function POST(req: Request) {
const body = await req.json();
const { id, price } = body;
await db.product.update({
where: { id },
data: { price },
});
revalidateTag(`product:${id}`);
return Response.json({ ok: true });
}Secara kasat mata ini benar: tulis ke database, lalu invalidasi cache. Tetapi dalam sistem nyata ada beberapa kondisi yang membuat hasil akhirnya tetap stale.
Kondisi pemicu yang membuat bug muncul
Race condition ini biasanya baru terlihat jika beberapa kondisi bertemu:
- Database commit tidak benar-benar selesai saat invalidasi dilakukan, atau ada lapisan write/read yang tidak langsung konsisten.
- Ada request paralel ke endpoint baca pada saat mutation sedang berlangsung.
- Data dibaca dari cache fetch bertag, sehingga satu request yang salah waktu dapat mengisi ulang cache dengan data lama.
- Ada lebih dari satu jalur baca, misalnya sebagian code path memakai tag A dan sebagian lain tag B, sehingga invalidasi tidak merata.
- Deployment production lebih cepat dan lebih paralel dibanding local, sehingga urutan event yang jarang terjadi di local menjadi nyata.
Poin paling berbahaya adalah request paralel yang datang tepat setelah invalidasi, tetapi sebelum sumber data benar-benar siap menyajikan nilai terbaru. Request itu bisa membaca data lama lalu menyimpannya kembali ke cache sebagai hasil “fresh” untuk sementara waktu.
Langkah reproduksi minimal
Agar tidak menebak-nebak, buat reproduksi kecil yang memaksa urutan event mendekati production. Tujuannya bukan meniru seluruh aplikasi, tetapi membuktikan bahwa stale data bisa muncul setelah revalidateTag.
1. Siapkan endpoint baca yang memakai tag
export async function getInventory(id: string) {
const res = await fetch(`${process.env.API_BASE_URL}/internal/inventory/${id}`, {
next: {
tags: [`inventory:${id}`],
revalidate: 600,
},
});
return res.json();
}2. Siapkan mutation yang mengubah data lalu invalidasi
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
export async function updateInventory(id: string, stock: number) {
await db.inventory.update({
where: { id },
data: { stock },
});
revalidateTag(`inventory:${id}`);
}3. Kirim request paralel
Jalankan urutan berikut dari script terpisah:
- Warm cache dengan request baca pertama.
- Panggil mutation untuk mengubah stok.
- Secara paralel, tembak beberapa request baca sesaat setelah mutation mulai atau tepat setelah response sukses.
- Catat apakah ada response yang masih mengandung stok lama, lalu apakah nilai lama itu bertahan di request berikutnya.
Di banyak kasus, bug tidak muncul pada request yang “ketinggalan” saja, tetapi juga request setelahnya, karena cache sudah terisi ulang oleh pembacaan lama itu.
Hipotesis yang sempat salah saat debugging
Dalam insiden seperti ini, tim sering menghabiskan waktu pada hipotesis yang terlihat masuk akal tetapi tidak menyentuh akar masalah. Beberapa yang paling umum:
1. “revalidateTag tidak jalan di production”
Biasanya salah. Log sering membuktikan fungsi tersebut memang dipanggil. Masalahnya bukan fungsi tidak dieksekusi, melainkan kapan dipanggil relatif terhadap commit data dan request lain.
2. “Browser cache yang menyimpan response lama”
Kalau stale muncul pada render server atau antar request server-side yang berbeda, fokuskan dulu ke backend cache dan sumber data. Browser cache memang bisa mengganggu diagnosis, tetapi sering bukan pelaku utama pada kasus App Router + fetch cache.
3. “Database update gagal”
Sering juga bukan. Query update bisa sukses, namun request baca paralel masih mengarah ke pembacaan lama karena timing, isolation, replication lag, atau cache yang terisi ulang terlalu cepat.
4. “Tag invalidation kurang spesifik”
Ini bisa menjadi masalah nyata, tetapi bukan satu-satunya penyebab. Bahkan tag yang benar pun tetap bisa menghasilkan data stale jika invalidasi dan repopulasi cache terjadi dalam urutan yang buruk.
Root cause: race condition antara write database, invalidasi cache, dan request paralel
Akar masalahnya dapat diringkas seperti ini:
- Cache lama untuk
product:123sudah ada. - Mutation datang untuk mengubah data produk 123.
- Backend memulai write ke database.
revalidateTag('product:123')dijalankan terlalu cepat, atau dijalankan setelah write tetapi sebelum semua pembaca akan melihat state baru secara konsisten.- Pada saat yang sama, request baca lain masuk karena halaman sedang dibuka atau prefetch berjalan di server.
- Request baca itu miss cache karena tag baru saja diinvalidasi.
- Namun sumber data yang dibaca request tersebut masih mengembalikan state lama.
- Hasil lama itu lalu di-cache kembali sebagai nilai baru pasca-invalidasi.
- Request berikutnya menerima data stale, walaupun mutation sebelumnya sukses.
Kalau database Anda memiliki replica untuk read, risikonya lebih besar. Write mungkin sudah commit di primary, tetapi read yang datang setelah invalidasi masih membaca replica yang tertinggal. Akibatnya cache diisi ulang dengan data yang salah waktu.
Inti masalah: invalidasi cache tidak menjamin request berikutnya pasti melihat data terbaru jika sumber data sendiri belum konsisten pada momen repopulasi.
Alur request yang perlu dipahami
Request A (mutation)
- update DB
- revalidateTag(product:123)
- return 200
Request B (read, paralel)
- cache miss karena baru diinvalidasi
- fetch ke sumber data
- sumber data masih mengembalikan versi lama
- response lama tersimpan lagi di cache dengan tag yang sama
Request C (read berikutnya)
- cache hit
- menerima data staleBegitu alurnya dipetakan, gejalanya biasanya langsung cocok dengan apa yang terlihat di production: bug intermiten, lebih sering saat concurrency tinggi, dan kadang hilang sendiri beberapa saat kemudian.
Strategi logging untuk membuktikan race condition
Jangan langsung mengubah kode tanpa bukti urutan event. Tambahkan logging yang cukup untuk merekonstruksi satu insiden end-to-end.
Data yang perlu dicatat
- Request ID unik untuk setiap mutation dan read.
- Entity ID, misalnya
productId. - Timestamp presisi tinggi di setiap tahap: mulai write, selesai write, invalidasi tag, mulai read, selesai read.
- Versi data atau
updatedAtdari record. - Sumber baca: primary, replica, service internal, atau query path tertentu.
- Status cache jika bisa diobservasi dari layer Anda sendiri, misalnya log saat helper baca dipanggil setelah invalidasi.
Contoh logging pada mutation
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
import { randomUUID } from 'crypto';
export async function POST(req: Request) {
const requestId = randomUUID();
const { id, price } = await req.json();
console.info('product.update.start', {
requestId,
productId: id,
ts: Date.now(),
});
const updated = await db.product.update({
where: { id },
data: { price },
select: { id: true, updatedAt: true },
});
console.info('product.update.committed', {
requestId,
productId: updated.id,
updatedAt: updated.updatedAt,
ts: Date.now(),
});
revalidateTag(`product:${id}`);
console.info('product.cache.revalidated', {
requestId,
productId: id,
ts: Date.now(),
});
return Response.json({ ok: true, requestId });
}Contoh logging pada jalur baca
export async function getProduct(id: string) {
const startedAt = Date.now();
const res = await fetch(`${process.env.API_BASE_URL}/internal/products/${id}`, {
next: {
tags: [`product:${id}`],
revalidate: 3600,
},
});
const data = await res.json();
console.info('product.read.result', {
productId: id,
updatedAt: data.updatedAt,
ts: Date.now(),
durationMs: Date.now() - startedAt,
});
return data;
}Kalau memungkinkan, log juga versi record yang terlihat oleh query mentah ke database. Tujuannya agar Anda bisa membedakan: stale berasal dari cache, dari replica, atau dari service internal yang juga punya cache sendiri.
Metrik yang perlu dicek di production
Selain log per request, cek beberapa metrik operasional berikut:
- Durasi mutation dan distribusinya saat traffic tinggi.
- Latency read setelah invalidasi, terutama spike sesaat setelah tag direvalidate.
- Jumlah request paralel ke resource yang sama.
- Replication lag jika memakai database replica.
- Error rate atau retry di service downstream.
- Pola updatedAt yang kembali mundur sesudah mutation sukses.
Jika Anda melihat mutation sukses pada T1, invalidasi pada T2, lalu ada read pada T3 yang masih membawa updatedAt lama, itu sinyal kuat bahwa sumber data belum konsisten saat cache diisi ulang.
Perbaikan yang aman
Tidak ada satu solusi universal, tetapi pola aman biasanya menggabungkan urutan commit yang tegas, pembacaan yang konsisten, dan pengurangan peluang repopulasi cache dengan data lama.
1. Pastikan invalidasi terjadi setelah commit benar-benar selesai
Jangan panggil revalidateTag sebelum transaksi selesai. Jika mutation melibatkan transaksi atau beberapa write, lakukan invalidasi hanya setelah seluruh perubahan committed.
await db.$transaction(async (tx) => {
await tx.product.update({
where: { id },
data: { price },
});
});
revalidateTag(`product:${id}`);Ini tidak otomatis menyelesaikan semua race condition, tetapi menghilangkan kasus invalidasi yang terlalu dini.
2. Untuk read-after-write kritis, baca dari sumber yang konsisten
Jika arsitektur Anda memakai read replica, pertimbangkan agar jalur baca yang akan mengisi cache setelah mutation mengambil data dari primary atau sumber yang menjamin update terbaru sudah terlihat. Trade-off-nya adalah beban primary bisa naik, jadi gunakan selektif pada resource yang sensitif terhadap stale data.
3. Kembalikan data hasil mutation langsung ke caller yang membutuhkan konfirmasi instan
Untuk alur backend tertentu, jangan langsung bergantung pada request baca berikutnya untuk menampilkan state terbaru. Bila memungkinkan, response mutation mengembalikan record yang sudah diupdate beserta updatedAt atau versi datanya. Ini tidak mengganti invalidasi cache, tetapi mengurangi kebutuhan melakukan read segera setelah write.
4. Gunakan versi data sebagai guardrail
Jika resource rentan race condition, simpan dan log monotonic version atau updatedAt. Saat data hasil baca memiliki versi lebih lama dari versi hasil mutation yang baru saja diketahui sistem, jangan izinkan nilai lama itu dianggap sebagai state terkini di alur bisnis yang kritis.
Guardrail ini tidak selalu mudah diterapkan pada cache framework level, tetapi sangat berguna pada service internal atau helper data access yang Anda kendalikan.
5. Hindari beberapa tag atau key untuk entitas yang sama tanpa aturan jelas
Jika satu produk bisa dibaca lewat product:123, products, category:abc, dan endpoint lain yang berbeda, pastikan invalidasi konsisten. Bug race condition sering makin sulit dilacak karena stale bukan hanya dari satu cache key, tetapi dari beberapa jalur baca yang tidak diinvalidasi bersamaan.
6. Pisahkan invalidasi agregat dan detail bila perlu
Data detail per ID dan data daftar hasil query biasanya punya pola invalidasi berbeda. Menggunakan satu tag global untuk semuanya memang sederhana, tetapi bisa menimbulkan lonjakan repopulasi cache dan memperbesar jendela race condition. Tag yang lebih terstruktur membantu membatasi blast radius.
Contoh perbaikan route handler yang lebih aman
Contoh berikut menekankan tiga hal: transaksi jelas, logging versi data, dan invalidasi setelah commit.
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
import { randomUUID } from 'crypto';
export async function POST(req: Request) {
const requestId = randomUUID();
const { id, price } = await req.json();
console.info('product.update.start', {
requestId,
productId: id,
ts: Date.now(),
});
const updated = await db.$transaction(async (tx) => {
return tx.product.update({
where: { id },
data: { price },
select: {
id: true,
price: true,
updatedAt: true,
},
});
});
console.info('product.update.committed', {
requestId,
productId: updated.id,
updatedAt: updated.updatedAt,
ts: Date.now(),
});
revalidateTag(`product:${id}`);
revalidateTag('products');
console.info('product.cache.revalidated', {
requestId,
productId: id,
tags: [`product:${id}`, 'products'],
ts: Date.now(),
});
return Response.json({
ok: true,
product: updated,
requestId,
});
}Catatan penting: contoh di atas lebih aman daripada invalidasi sebelum commit, tetapi belum tentu cukup jika masalah utama adalah read replica yang lag atau service downstream yang eventual-consistent. Dalam kasus seperti itu, sumber baca pasca-invalidasi juga harus dibenahi.
Cara verifikasi bahwa perbaikan benar-benar menyelesaikan masalah
Jangan berhenti setelah bug “sudah tidak terlihat”. Verifikasi harus dirancang untuk membuktikan bahwa urutan event kini aman.
1. Uji concurrency terarah
Buat test script atau load scenario kecil yang:
- melakukan warm cache,
- mengirim mutation,
- mengirim beberapa read paralel tepat sesudahnya,
- memeriksa bahwa semua read sesudah commit mengembalikan versi data yang sama atau lebih baru.
2. Bandingkan updatedAt sebelum dan sesudah fix
Sebelum fix, Anda mungkin menemukan pola: mutation sukses dengan updatedAt=T2, lalu read berikutnya masih mengembalikan T1. Setelah fix, pola ini seharusnya hilang atau turun drastis.
3. Uji pada environment yang mendekati production
Local dev sering terlalu serial dan terlalu lambat untuk memunculkan race condition yang sama. Jalankan verifikasi di staging dengan concurrency nyata, latensi serupa production, dan topologi database yang sama jika memungkinkan.
4. Pantau beberapa hari setelah deploy
Tambahkan metrik atau log sementara untuk menghitung insiden read yang mengembalikan versi lebih lama dari mutation terakhir pada entity yang sama. Ini memberi bukti kuantitatif bahwa bug benar-benar turun.
Guardrail agar tidak terulang
Masalah ini sering kembali saat codebase makin besar. Beberapa guardrail berikut layak diterapkan:
1. Standarkan helper mutation
Buat pola baku: write dalam transaksi, log versi data, lalu invalidasi tag di satu tempat. Hindari setiap tim menulis urutan mutation sendiri-sendiri.
2. Dokumentasikan kontrak cache per resource
Tulis jelas tag apa yang dipakai untuk detail, list, dan agregasi. Tanpa kontrak ini, invalidasi akan mudah tidak lengkap.
3. Audit jalur baca yang mengisi cache
Pastikan Anda tahu apakah pembacaan berasal dari primary, replica, atau service lain. Banyak bug stale dikira berasal dari Next.js, padahal sumbernya adalah jalur data yang tidak konsisten.
4. Tambahkan observability berbasis versi data
updatedAt, revision number, atau sequence ID sangat membantu. Tanpa indikator versi, sulit membedakan response lama yang sah dari stale data akibat race condition.
5. Gunakan invalidasi sehemat mungkin, tetapi lengkap
Terlalu banyak invalidasi memperbesar churn cache. Terlalu sedikit invalidasi membuat stale permanen. Kuncinya adalah pemetaan tag yang presisi dan konsisten.
Kesimpulan
Pada Next.js App Router, bug stale data setelah mutation meski sudah memanggil revalidateTag sering bukan berarti API invalidasinya rusak. Penyebab yang lebih realistis adalah race condition: cache diinvalidasi, lalu request paralel mengisi ulang cache dengan data lama karena write belum terlihat konsisten oleh jalur baca.
Pendekatan debugging yang efektif adalah memetakan urutan event secara konkret: kapan transaksi commit, kapan revalidateTag dipanggil, request baca mana yang datang setelahnya, dan versi data apa yang benar-benar terbaca. Perbaikannya biasanya melibatkan invalidasi setelah commit, memastikan sumber baca pasca-write konsisten, logging versi data, dan pengujian concurrency yang terarah. Jika langkah-langkah ini diterapkan, bug yang semula sporadis di production akan jauh lebih mudah ditangani dan dicegah muncul kembali.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!