Hydration mismatch pada aplikasi SSR modern biasanya muncul saat HTML hasil render server tidak cocok dengan render pertama di browser. Masalah utamanya sering bukan sekadar komponen yang salah, melainkan hilangnya jejak state: dari mana data awal datang, kapan ia berubah, siapa yang menulis ulang, dan mengapa keputusan render di server berbeda dengan client.

Pada tim yang besar, situasinya mirip agen yang bisa menghasilkan output tetapi tidak menyimpan reasoning lengkapnya. Di frontend SSR, UI bisa terlihat benar di satu sisi, tetapi keputusan render tersebar di server fetch, cache, cookie, local storage, router state, revalidation, dan event client. Saat mismatch muncul, yang perlu dicari bukan hanya “komponen mana yang error”, tetapi jejak state yang membentuk HTML server dan render client pertama.

Mengapa hydration mismatch sulit dilacak

Pada React dan framework SSR seperti Next.js, alur sederhananya adalah:

  1. Server merender HTML berdasarkan state dan data yang tersedia saat request diproses.
  2. Browser menerima HTML lalu React melakukan hydration dengan menjalankan tree komponen di client.
  3. Jika render pertama di client menghasilkan struktur atau nilai yang berbeda dari HTML server, React mengeluarkan peringatan atau membuang subtree tertentu lalu merender ulang.

Secara teori ini sederhana. Dalam praktiknya, sumber state untuk render pertama sering tersebar:

  • data hasil fetch di server,
  • cookie atau header request,
  • cache CDN atau cache aplikasi,
  • state global yang di-serialize ke HTML,
  • nilai dari browser seperti timezone, locale, viewport,
  • side effect dari useEffect atau event listener client.

Akibatnya, tim hanya melihat gejala seperti:

  • warning Text content does not match server-rendered HTML,
  • komponen berkedip saat load,
  • class CSS atau atribut berbeda saat hydration,
  • data daftar berubah urutan setelah halaman tampil,
  • UI hanya rusak di region, device, atau user tertentu.

Kalau jejak asal state tidak terlihat, proses debug menjadi menebak-nebak.

Gejala umum dan akar masalah paling sering

1. Nilai non-deterministik dipakai saat render

Contoh umum: Date.now(), Math.random(), ID acak, timezone lokal browser, atau format tanggal yang berbeda antara lingkungan server dan client.

export function Header() {
  return <p>Rendered at: {Date.now()}</p>;
}

Kode ini hampir pasti memicu mismatch karena nilai saat server render dan client render pertama berbeda.

2. Kondisi bercabang berdasarkan API browser

export function Sidebar() {
  const isMobile = window.innerWidth < 768;
  return isMobile ? <MobileNav /> : <DesktopNav />;
}

Di server, window tidak tersedia. Jika disiasati dengan fallback yang berbeda dari kondisi browser nyata, hasil render awal bisa tidak sama.

3. State awal di client menimpa payload SSR

Ini sering terjadi saat komponen membaca data dari local storage, store global, atau cache query segera setelah mount, lalu hasilnya tidak sinkron dengan HTML server.

4. Fetch ganda dengan hasil berbeda

Server mengambil data A, lalu client melakukan fetch ulang dan menerima data B sebelum atau saat hydration selesai. Hasilnya bisa berupa urutan list berubah, jumlah item bergeser, atau flag loading hilang terlalu cepat.

5. Key list tidak stabil

Jika list dirender dengan key yang berubah antar lingkungan, React sulit mencocokkan node hasil server dengan tree di client.

items.map((item, index) => <Row key={index} item={item} />)

Menggunakan index sebagai key tidak selalu salah, tetapi berbahaya jika urutan item bisa berubah antara server dan client.

6. Render dipengaruhi cookie, auth, atau eksperimen

SSR sering membaca cookie atau header untuk menentukan varian UI. Jika client membaca sumber lain atau inisialisasi auth belum selesai, tree yang dirender bisa berbeda.

Prinsip utama: lacak keputusan render, bukan hanya error

Untuk debug hydration mismatch, pendekatan yang paling berguna adalah membuat trace keputusan render. Tujuannya:

  • mengetahui state apa yang dipakai server untuk menghasilkan HTML,
  • mengetahui state apa yang dipakai client pada render pertama,
  • membandingkan keduanya dengan konteks waktu dan sumber data.

Alih-alih hanya logging nilai akhir, catat juga asal nilai. Misalnya:

  • user.source = 'cookie'
  • products.source = 'server-fetch'
  • theme.source = 'local-storage'
  • locale.source = 'accept-language-header'

Dengan begitu, saat teks atau struktur berbeda, tim bisa menjawab: nilai ini datang dari mana, kapan dibaca, dan apakah sumbernya tersedia di server maupun client?

Teknik praktis memberi jejak state pada SSR

1. Buat snapshot state awal yang eksplisit

Jangan biarkan komponen penting membaca sumber state secara diam-diam dari banyak tempat. Kumpulkan state awal di boundary SSR, lalu teruskan sebagai payload yang jelas.

export async function buildInitialState(request) {
  const user = await getUserFromRequest(request);
  const products = await fetchFeaturedProducts();
  const locale = getLocaleFromRequest(request);

  return {
    user: {
      value: user ? { id: user.id, name: user.name } : null,
      source: 'request-cookie'
    },
    products: {
      value: products,
      source: 'server-fetch'
    },
    locale: {
      value: locale,
      source: 'request-header'
    },
    trace: {
      requestId: request.headers.get('x-request-id') || null,
      renderedAt: new Date().toISOString()
    }
  };
}

Manfaatnya:

  • server memiliki kontrak state awal yang konsisten,
  • payload dapat diinspeksi saat insiden,
  • komponen tidak perlu menebak-nebak dari mana data berasal.

2. Serialize payload SSR untuk inspeksi debug

Pada mode debug internal, simpan snapshot state awal ke HTML agar bisa dibandingkan dengan state client. Hindari memasukkan data sensitif.

<script id="__SSR_TRACE__" type="application/json">
{"locale":{"value":"id-ID","source":"request-header"},"trace":{"requestId":"req-123"}}
</script>

Di client, baca payload ini dan log saat hydration dimulai.

function readSsrTrace() {
  const el = document.getElementById('__SSR_TRACE__');
  if (!el?.textContent) return null;
  try {
    return JSON.parse(el.textContent);
  } catch {
    return null;
  }
}

const ssrTrace = readSsrTrace();
console.debug('SSR trace', ssrTrace);

Gunakan teknik ini terutama saat insiden atau pada environment non-produksi. Jangan serialize token, header rahasia, atau data pribadi yang tidak perlu.

3. Tambahkan timestamp dan fase fetch

Masalah SSR sering terjadi karena data berubah di antara render server dan fetch client. Karena itu, bukan hanya nilainya yang perlu dicatat, tetapi juga kapan nilai itu diperoleh.

async function tracedFetch(name, fetcher) {
  const startedAt = Date.now();
  const data = await fetcher();
  const endedAt = Date.now();

  return {
    value: data,
    meta: {
      name,
      startedAt,
      endedAt,
      durationMs: endedAt - startedAt
    }
  };
}

Jika server mengambil daftar produk pukul X dan client melakukan refetch pukul X+800ms, lalu hasil berubah karena cache atau revalidation, akar mismatch lebih mudah terlihat.

4. Catat event client yang menimpa state awal

Banyak mismatch “terlihat seperti SSR”, padahal sebenarnya state sudah ditimpa sangat cepat oleh event client: pembacaan local storage, inisialisasi feature flag, sinkronisasi auth, atau listener media query.

useEffect(() => {
  const storedTheme = localStorage.getItem('theme');
  if (storedTheme) {
    console.debug('theme override from localStorage', {
      previousSource: initialTheme.source,
      nextSource: 'local-storage',
      value: storedTheme
    });
    setTheme(storedTheme);
  }
}, [initialTheme.source]);

Log seperti ini membantu membedakan:

  • mismatch saat hydration pertama, atau
  • UI berubah tepat setelah hydration karena override client.

5. Gunakan request ID untuk korelasi log

Jika aplikasi memiliki logging di server dan observability di client, kirim requestId atau correlation ID ke payload SSR. Saat warning muncul di browser, Anda bisa melacak request server yang menghasilkan HTML tersebut.

Ini sangat berguna pada kasus yang tidak reproducible secara lokal, misalnya hanya terjadi pada user dengan kombinasi cookie, region, dan cache tertentu.

Contoh alur debug pada React/Next.js

Misalkan ada halaman produk yang SSR, lalu user melaporkan jumlah item di header berbeda sesaat setelah halaman terbuka.

Gejala

  • Server HTML menampilkan Cart (0).
  • Begitu hydration selesai, UI berubah menjadi Cart (3).
  • Kadang muncul warning mismatch, kadang hanya flicker.

Akar masalah yang sering terjadi

Server tidak tahu isi cart anonim karena cart disimpan di local storage, tetapi komponen header langsung merender jumlah cart dari state client pada render pertama. Jika implementasinya tidak hati-hati, server dan client menghasilkan teks berbeda.

Pendekatan yang lebih aman

Render nilai yang stabil saat SSR, lalu sinkronkan setelah mount.

function CartBadge({ initialCount = 0 }) {
  const [count, setCount] = React.useState(initialCount);
  const [hydrated, setHydrated] = React.useState(false);

  React.useEffect(() => {
    setHydrated(true);
    const raw = localStorage.getItem('cart');
    const items = raw ? JSON.parse(raw) : [];
    setCount(items.length);
  }, []);

  return (
    <span data-hydrated={hydrated}>
      Cart ({count})
    </span>
  );
}

Mengapa ini bekerja:

  • render server dan render client pertama sama-sama memakai initialCount,
  • akses local storage dipindahkan ke useEffect, sehingga tidak ikut menentukan hasil hydration pertama,
  • perubahan setelah mount menjadi transisi state yang disengaja, bukan mismatch.

Trade-off: user mungkin melihat nilai placeholder sesaat. Ini sering lebih baik daripada mismatch yang merusak tree atau menyebabkan UI meloncat tanpa kontrol.

Pola implementasi trace yang bisa dipakai tim

Buat struktur metadata state sederhana

Tidak perlu sistem rumit. Cukup biasakan setiap state awal penting memiliki:

  • value
  • source
  • resolvedAt
  • requestId bila relevan
type TracedValue<T> = {
  value: T;
  source: string;
  resolvedAt?: string;
  requestId?: string | null;
};

State seperti ini jauh lebih mudah diaudit saat ada bug rendering.

Bedakan state SSR, hydration, dan interaksi

Secara operasional, tim sebaiknya membedakan tiga fase:

  1. SSR state: data yang dipakai untuk menghasilkan HTML server.
  2. Hydration state: data yang dipakai client pada render pertama.
  3. Interactive state: data setelah effect, event, dan refetch berjalan.

Jika ketiga fase ini dicampur, debug akan kabur. Banyak insiden selesai lebih cepat hanya dengan menanyakan: perbedaan ini terjadi di fase mana?

Jangan baca sumber client-only saat render awal jika output harus stabil

Sumber seperti window, document, localStorage, ukuran viewport, dan media query sebaiknya tidak menentukan markup awal kecuali Anda memang menerima trade-off non-SSR atau menggunakan fallback yang konsisten.

Checklist debugging hydration mismatch

  1. Reproduksi dengan kondisi yang sama
    Gunakan cookie, locale, auth state, dan URL yang sama dengan user yang melapor.
  2. Bandingkan HTML server dengan render client pertama
    Cari node pertama yang berbeda: teks, atribut, urutan list, class, atau struktur elemen.
  3. Identifikasi semua input render untuk komponen itu
    Props, context, store, cookie, query param, local storage, feature flag, dan hasil fetch.
  4. Tandai asal tiap input
    Apakah nilainya berasal dari server fetch, cache client, browser API, atau event setelah mount.
  5. Periksa nilai non-deterministik
    Date.now(), random ID, formatting berbasis timezone, dan key yang tidak stabil.
  6. Periksa fetch ganda
    Apakah server dan client memanggil endpoint yang sama tetapi lewat cache atau waktu berbeda.
  7. Audit branch render
    Pastikan kondisi if atau ternary tidak bergantung pada API browser saat SSR.
  8. Cek serialisasi payload SSR
    Apakah data awal yang dikirim ke client benar-benar sama dengan yang dipakai merender HTML.
  9. Tambahkan trace sementara
    Log request ID, source state, waktu fetch, dan event override client.
  10. Validasi perbaikan pada slow network
    Mismatch kadang hanya terlihat saat rehydration lebih lambat atau refetch datang pada timing tertentu.

Pencegahan yang realistis untuk tim

1. Perlakukan render awal sebagai kontrak

HTML server dan render client pertama harus dianggap sebagai kontrak yang sama. Jika ada state yang belum pasti di client, render fallback yang konsisten dulu.

2. Minimalkan sumber kebenaran untuk initial state

Semakin banyak tempat membaca state awal, semakin mudah jejak keputusan hilang. Usahakan satu jalur utama untuk state SSR, lalu sinkronkan sumber lain secara eksplisit.

3. Buat aturan code review untuk SSR-sensitive code

Contoh aturan praktis:

  • hindari Date.now() dan Math.random() di render,
  • hindari akses browser API di render SSR,
  • gunakan key yang stabil,
  • dokumentasikan source dari state awal penting,
  • bedakan clearly antara fallback SSR dan override client.

4. Siapkan mode debug internal

Mode ini bisa menampilkan payload SSR, source state, request ID, dan event override. Tidak perlu aktif di semua request produksi, tetapi sangat membantu saat insiden UI sulit dijelaskan.

5. Uji kasus lintas environment

Hydration mismatch sering dipicu oleh hal yang tidak terlihat di laptop developer: timezone server berbeda, locale browser berbeda, cache stale, atau cookie eksperimen tertentu. Tambahkan pengujian manual atau otomatis untuk variasi ini pada komponen yang kritis.

Batasan dan trade-off pendekatan trace state

Memberi trace pada state tentu ada biaya:

  • payload SSR bisa membesar jika metadata berlebihan,
  • log client dapat bising jika semua event dicatat,
  • perlu disiplin tim untuk menjaga source metadata tetap akurat.

Karena itu, fokuskan trace pada state yang benar-benar mempengaruhi markup awal: auth, locale, currency, feature flag, list data utama, dan state layout penting. Untuk produksi, pertimbangkan sampling atau mode debug terkontrol.

Penutup

Jejak state untuk debug hydration mismatch bukan sekadar teknik logging, tetapi cara berpikir. Saat server dan client menghasilkan UI berbeda, masalahnya sering muncul karena keputusan render tersebar dan tidak dapat diaudit: sebagian dari request, sebagian dari cache, sebagian dari fetch async, sebagian lagi dari browser setelah mount.

Jika tim mulai mencatat asal state awal, waktu state diperoleh, dan event yang menimpanya, insiden hydration mismatch akan jauh lebih mudah dibedah. Fokus utamanya sederhana: jangan hanya lihat nilai akhirnya, tetapi lacak reasoning render yang membentuk HTML server dan render client pertama.