State hydration stabil untuk SSR saat read replica tertinggal berarti satu hal sederhana: HTML awal yang dikirim server dan state awal yang dipakai JavaScript di browser harus berasal dari snapshot data yang sama, atau setidaknya dari versi data yang kompatibel. Jika tidak, Anda akan melihat gejala seperti warning hydration mismatch, komponen meloncat setelah mount, tombol tiba-tiba berubah status, angka counter berganti, atau daftar item tersusun ulang beberapa milidetik setelah halaman tampil.

Masalah ini sering muncul pada sistem yang sudah di-scale-out: render SSR membaca dari cache atau read replica, sementara fetch klien setelah hydration membaca dari primary, edge cache lain, atau endpoint API dengan jalur data berbeda. Hasilnya bukan sekadar warning kosmetik. UI bisa menampilkan status yang salah, event binding gagal pada subtree tertentu, dan debugging menjadi sulit karena bug hanya muncul saat lag replikasi atau invalidasi cache sedang terjadi.

Mengapa mismatch hydration terjadi pada arsitektur scale-out

Pada aplikasi kecil, SSR dan fetch klien sering membaca dari sumber yang sama sehingga perbedaan state jarang terlihat. Namun setelah backend dipisah untuk menangani beban puncak, alur data biasanya tidak lagi tunggal. Misalnya:

  • render SSR membaca dari read replica agar beban primary turun,
  • API mutasi menulis ke primary,
  • permintaan API dari browser sesudah hydration membaca dari primary atau replica lain,
  • halaman publik di-cache pada layer CDN atau reverse proxy,
  • beberapa data ringkas sudah diprecompute di Redis atau materialized view.

Pola ini valid untuk scaling, tetapi memperbesar peluang satu halaman dirender dari snapshot A lalu di-hydrate dengan snapshot B. Jika B berbeda secara struktural atau tekstual dari A, framework SSR akan mendeteksi mismatch.

Contoh alur request yang memicu bug

  1. User mengubah status pesanan menjadi paid. Write masuk ke primary.
  2. Beberapa milidetik kemudian user membuka halaman detail pesanan.
  3. Server SSR membaca detail pesanan dari read replica yang masih tertinggal dan melihat status pending.
  4. HTML dikirim dengan badge Pending.
  5. Browser menjalankan JavaScript. Fetch awal untuk state klien memanggil endpoint yang membaca dari primary dan menerima status paid.
  6. Saat hydration, DOM awal menyatakan Pending, sementara virtual tree ingin merender Paid.

Kalau hanya beda teks, framework mungkin mengganti DOM dan memberi warning. Kalau beda struktur, misalnya tombol Bayar hanya muncul pada status pending, maka subtree dan event handler bisa menjadi tidak konsisten.

Gejala yang umum terlihat di UI SSR

  • Warning hydration mismatch di console browser.
  • Flash of incorrect content: konten awal benar menurut replica lama, lalu berubah tiba-tiba.
  • Tombol atau badge lompat status setelah mount.
  • Daftar item berganti urutan karena data klien memakai snapshot lebih baru.
  • Event tidak terpasang pada node yang diharapkan jika markup awal terlalu berbeda.
  • Bug intermiten yang hanya terjadi saat traffic tinggi, replikasi tertinggal, atau cache belum sinkron.

Jika bug hanya muncul sesekali di production tetapi sulit direproduksi di lokal, curigai perbedaan sumber data antara SSR dan state klien, bukan hanya bug komponen frontend.

Root cause: SSR dan hydration tidak berbagi kontrak snapshot

Akar masalahnya bukan sekadar “data berubah”, melainkan tidak adanya kontrak eksplisit bahwa HTML server dan state awal klien berasal dari snapshot yang sama. Banyak tim menganggap hydration akan aman selama API “akhirnya konsisten”, padahal hydration butuh konsistensi yang lebih ketat pada momen render awal.

Beberapa penyebab umum:

  • Read replica lag: SSR membaca data yang lebih lama daripada API klien.
  • Cache key yang tidak memasukkan versi data: HTML atau payload lama dipakai bersama fetch baru.
  • Endpoint SSR dan endpoint klien memakai jalur agregasi berbeda: satu dari replica, satu dari cache, satu dari service lain.
  • Stale-while-revalidate tanpa pembatasan: HTML stale dikirim cepat, tetapi fetch klien langsung mengembalikan data fresh yang berbeda.
  • Transformasi presentasi berbeda antara server dan klien, misalnya sorting, filtering, atau normalisasi dilakukan di tempat yang tidak sama.

Strategi mitigasi yang paling praktis

Strategi terbaik bukan selalu “pakai primary untuk semua request”. Itu mahal dan menghilangkan manfaat scale-out. Yang lebih realistis adalah memastikan SSR dan hydration memiliki referensi snapshot yang sama atau tahu kapan mereka tidak boleh menggabungkan snapshot berbeda.

1. Serialize versi snapshot atau timestamp data ke HTML

Langkah paling berguna adalah mengirim metadata snapshot bersama HTML SSR, misalnya:

  • versi record,
  • timestamp data terakhir yang dipakai render,
  • LSN/offset/sequence internal jika sistem Anda punya konsep itu,
  • ETag atau hash payload yang stabil.

Metadata ini kemudian dipakai klien untuk memutuskan apakah fetch awal masih kompatibel dengan HTML yang sedang di-hydrate.

<script id="__SSR_SNAPSHOT__" type="application/json">
{
  "orderId": "ord_123",
  "snapshotVersion": "2026-08-28T10:15:30.000Z",
  "data": {
    "status": "pending",
    "total": 125000
  }
}
</script>

Di sisi klien, gunakan payload ini sebagai sumber state awal. Jangan langsung menggantinya dengan fetch baru sebelum Anda tahu apakah versi data baru aman untuk diaplikasikan.

const ssr = JSON.parse(document.getElementById('__SSR_SNAPSHOT__').textContent)
let state = ssr.data

async function hydrateSafely() {
  const res = await fetch(`/api/orders/${ssr.orderId}`)
  const fresh = await res.json()

  if (fresh.snapshotVersion === ssr.snapshotVersion) {
    state = fresh.data
    return
  }

  // Jika berbeda, lakukan update terkontrol setelah hydration selesai,
  // atau tampilkan indikator bahwa data sedang diperbarui.
  queuePostHydrationUpdate(fresh.data)
}

Mengapa ini bekerja? Karena Anda memisahkan dua fase yang sering tercampur:

  1. Hydration correctness: virtual tree awal harus cocok dengan HTML.
  2. Freshness update: data lebih baru boleh masuk setelah aplikasi sudah stabil.

Kesalahan umum di sini adalah tetap menjalankan fetch klien yang langsung menimpa state sebelum hydration selesai.

2. Pin SSR dan fetch awal ke snapshot yang sama

Kalau aplikasi Anda memiliki kontrol backend yang cukup, pendekatan yang lebih kuat adalah snapshot pinning. Artinya SSR menghasilkan token snapshot, lalu klien mengirim token itu saat fetch awal. Backend kemudian:

  • mengembalikan payload yang sama persis dengan SSR, atau
  • menunda respons sampai sumber data minimal sudah mencapai versi tersebut, atau
  • mengembalikan sinyal bahwa snapshot itu belum tersedia di jalur baca tertentu.

Secara konseptual:

GET /page/orders/ord_123
-> SSR memakai snapshotVersion=1704123456
-> HTML berisi data + snapshotVersion

GET /api/orders/ord_123?min_version=1704123456
-> API hanya mengembalikan data dari versi >= 1704123456
   atau fallback ke primary jika replica belum sampai

Ini sangat berguna pada arsitektur dengan primary untuk write dan replica untuk read. Jika replica belum mencapai versi minimum, Anda bisa memilih:

  • fallback ke primary untuk request pertama,
  • menunggu singkat dengan timeout kecil,
  • mengembalikan payload stale yang ditandai jelas dan menunda re-render.

Trade-off-nya jelas: kompleksitas backend naik, tetapi bug hydration turun drastis.

3. Hati-hati dengan stale-while-revalidate

Stale-while-revalidate bagus untuk latency, tetapi berbahaya untuk SSR jika HTML stale dikirim lalu klien langsung memuat data fresh yang berbeda. Ini terutama bermasalah pada halaman yang menampilkan status transaksi, inventori, role user, atau jumlah item yang memengaruhi struktur UI.

Praktik yang lebih aman:

  • gunakan payload SSR sebagai state awal dan tunda revalidasi sampai hydration selesai,
  • jangan lakukan re-render agresif pada komponen yang struktur DOM-nya sensitif,
  • untuk data kritis, matikan SWR pada request awal atau pin ke versi yang sama,
  • bedakan data yang boleh stale secara visual dari data yang tidak boleh.

Contoh pembagian yang sehat:

  • Boleh stale singkat: jumlah view, rekomendasi sekunder, statistik non-kritis.
  • Jangan stale untuk hydration awal: status pembayaran, hak akses, harga final, CTA yang menentukan aksi berikutnya.

4. Gunakan client-only boundary seperlunya

Jika ada bagian UI yang sangat bergantung pada data yang berubah cepat dan sulit dipin ke snapshot yang sama, pertimbangkan menjadikannya client-only boundary. Ide dasarnya: jangan SSR bagian tersebut, atau SSR hanya placeholder yang stabil.

Contoh yang cocok:

  • widget status realtime,
  • indikator stok yang berubah cepat,
  • ringkasan notifikasi personal yang tidak penting untuk SEO.

Namun ini bukan solusi default. Terlalu banyak client-only boundary akan:

  • mengurangi manfaat SSR,
  • memperburuk time to interactive pada perangkat lambat,
  • menambah flash placeholder,
  • mengorbankan konsistensi pengalaman awal.

Gunakan hanya untuk area yang memang sulit dijaga konsistensinya secara ekonomis.

5. Samakan transformasi data antara server dan klien

Kadang sumber datanya sama, tetapi hasil render tetap beda karena transformasi dilakukan di dua tempat dengan hasil tidak identik. Contoh umum:

  • server mengurutkan berdasarkan updated_at, klien mengurutkan ulang berdasarkan field turunan,
  • server memfilter item null, klien tidak,
  • server memformat tanggal dalam zona waktu tertentu, klien memakai zona lokal browser,
  • server merender list dengan key berbeda dari identitas data sebenarnya.

Untuk komponen SSR, usahakan payload yang sudah siap render dikirim apa adanya. Semakin banyak logika transformasi yang dipisah antara server dan klien, semakin tinggi risiko mismatch.

Pola implementasi lintas framework

Baik di Next.js, Nuxt, maupun Inertia, prinsipnya sama: payload yang dipakai saat hydration harus identik dengan payload yang membentuk HTML awal.

Next.js / Nuxt

  • Pastikan data SSR diserialisasi ke payload halaman dan dipakai langsung oleh komponen saat mount.
  • Jangan memicu fetch ulang yang menimpa state sinkron pada fase awal tanpa cek versi.
  • Untuk route yang sensitif, sertakan snapshotVersion pada props atau payload server.
  • Jika memakai cache server atau edge, masukkan dimensi versi yang relevan ke cache key bila memungkinkan.

Inertia

  • Anggap props awal sebagai kontrak snapshot untuk render pertama.
  • Jika ada partial reload atau background reload, lakukan setelah halaman stabil.
  • Untuk data yang rawan berubah cepat, pisahkan dari props SSR utama bila tidak perlu menentukan struktur markup awal.

Anda tidak perlu bergantung pada fitur framework tertentu untuk menerapkan ini. Yang penting adalah disiplin pada alur data awal.

Contoh kontrak API yang lebih aman

Berikut contoh respons API generik yang membantu menjaga stabilitas hydration:

{
  "data": {
    "id": "ord_123",
    "status": "pending",
    "total": 125000
  },
  "snapshotVersion": "2026-08-28T10:15:30.000Z",
  "servedFrom": "replica-a"
}

Lalu klien bisa menerapkan kebijakan sederhana:

  • jika versi sama, aman mengganti state tanpa efek visual berarti,
  • jika versi lebih baru, update setelah hydration selesai,
  • jika versi lebih lama daripada SSR, jangan timpa state lokal,
  • jika respons datang dari replica yang belum cukup baru, retry ke primary untuk request tertentu.

servedFrom tidak wajib tampil ke user, tetapi sangat membantu untuk observability dan debugging.

Checklist debugging saat hydration mismatch hanya muncul di production

  1. Log sumber data SSR dan klien: primary, replica mana, cache hit/miss, region, atau edge node.
  2. Log versi snapshot pada HTML SSR dan pada respons fetch klien pertama.
  3. Bandingkan payload mentah, bukan hanya output DOM.
  4. Cek apakah klien melakukan refetch otomatis saat mount.
  5. Audit cache key: apakah user, locale, role, atau versi data ikut diperhitungkan.
  6. Periksa read replica lag pada saat warning muncul.
  7. Cari transformasi ganda: sorting, filtering, format tanggal, desimal, locale.
  8. Validasi key list agar identitas item stabil.
  9. Simulasikan lag di staging dengan menunda jalur baca tertentu atau memaksa stale cache.
  10. Instrumentasi hydration error di frontend dan korelasikan dengan request ID backend.

Debugging akan jauh lebih mudah jika setiap request SSR menyematkan request ID dan metadata snapshot ke HTML, lalu metadata yang sama ikut dikirim pada fetch awal dari browser.

Kesalahan umum yang sering tidak disadari

  • Menganggap warning hydration aman diabaikan. Kadang memang UI tetap terlihat benar, tetapi event binding atau state lokal bisa rusak.
  • Mengandalkan eventual consistency untuk render awal. Eventual consistency cocok untuk sinkronisasi data jangka pendek, bukan untuk kontrak hydration.
  • Memperbaiki gejala dengan menonaktifkan SSR seluruh halaman. Ini sering terlalu mahal untuk SEO dan perceived performance.
  • Menyamakan semua data sebagai sama pentingnya. Tidak semua field butuh konsistensi kuat; pilih area yang benar-benar menentukan struktur UI.
  • Tidak membedakan freshness dan correctness. Data terbaru belum tentu prioritas utama pada milidetik pertama; yang utama adalah HTML dan state awal cocok dulu.

Trade-off konsistensi vs performa

Tidak ada solusi tunggal yang ideal untuk semua halaman. Anda perlu memilih berdasarkan nilai bisnis halaman dan sensitivitas UI-nya.

Pilih konsistensi lebih kuat jika:

  • halaman menampilkan status transaksi atau otorisasi,
  • perbedaan satu field mengubah tombol, form, atau alur aksi user,
  • bug hydration bisa memicu tindakan salah dari user.

Pilih performa lebih agresif jika:

  • data bersifat informasional dan tidak menentukan aksi utama,
  • sedikit stale dapat diterima,
  • client-only atau revalidasi pasca-hydration tidak mengganggu pengalaman inti.

Praktik yang sering efektif di sistem besar adalah hybrid policy:

  • bagian kritis memakai snapshot pinning atau fallback ke primary,
  • bagian non-kritis memakai cache dan SWR biasa,
  • komponen yang sangat dinamis dipindah ke client-only boundary.

Rekomendasi implementasi minimum yang layak

Jika Anda ingin perbaikan yang cepat tanpa redesain besar, mulai dari empat langkah ini:

  1. Serialize snapshotVersion bersama payload SSR.
  2. Gunakan payload SSR sebagai satu-satunya state awal hydration.
  3. Tunda refetch otomatis sampai hydration selesai, lalu bandingkan versinya sebelum menimpa state.
  4. Tambahkan observability: log request ID, sumber data, cache status, dan versi snapshot.

Empat langkah ini biasanya sudah cukup untuk mengubah bug acak yang sulit ditangkap menjadi alur yang bisa dipahami dan dikendalikan.

Penutup

Bug UI SSR saat HTML server dirender dari snapshot berbeda dengan state klien bukan masalah frontend murni. Ini adalah masalah kontrak data pada arsitektur scale-out: cache, read replica, dan jalur baca terpisah mempercepat sistem, tetapi juga membuat hydration rentan jika snapshot tidak dijaga.

Kuncinya adalah sederhana namun disiplin: HTML SSR dan state awal klien harus berbagi snapshot, versi, atau aturan kompatibilitas yang jelas. Setelah itu, data yang lebih baru boleh masuk secara terkontrol. Dengan pendekatan ini, Anda tetap bisa memanfaatkan read replica, cache, dan pemisahan sumber data tanpa membayar mahal lewat hydration mismatch yang sulit didiagnosis.