SSR stabil untuk klien email berarti HTML awal yang dikirim server harus cocok dengan state pertama yang dipakai saat aplikasi di-hydrate di browser. Pada UI inbox, ini lebih sulit daripada halaman statis karena daftar thread, badge unread, waktu relatif, selection state, preferensi lokal, dan layout responsif bisa berubah dalam jeda sangat pendek antara render server dan render klien.

Jika tidak ditangani, hasilnya adalah hydration drift: teks berubah sesaat setelah load, badge unread meloncat, urutan thread bergeser, item yang awalnya dipilih hilang, atau framework memunculkan peringatan mismatch. Dalam konteks produk email modern—seperti arah “The Future of Email” yang menuntut inbox cepat, kaya state, dan tetap responsif—SSR tidak cukup hanya cepat. Ia juga harus deterministik.

Mengapa hydration drift sering terjadi pada inbox

Inbox adalah kombinasi yang rumit: data sering berubah, banyak state diturunkan dari preferensi pengguna, dan sebagian logika baru valid di browser. Saat server merender satu versi UI lalu browser memulai dengan versi lain, framework harus memilih antara menambal DOM, me-render ulang subtree, atau memperingatkan mismatch. Pada aplikasi mirip klien email, beberapa penyebab paling umum adalah:

  • Waktu relatif seperti “baru saja”, “2 menit lalu”, atau label hari yang berubah tiap menit.
  • Status baca yang berubah karena event real-time, prefetch, atau aksi di tab lain.
  • Badge unread yang dihitung ulang dari sumber data berbeda antara server dan klien.
  • Urutan thread yang berubah karena sorting berbasis timestamp terbaru, pinning, atau merge hasil fetch lanjutan.
  • Preferensi lokal seperti densitas list, tema, panel split-view, folder terakhir, dan locale yang hanya tersedia di browser.
  • Viewport yang memengaruhi jumlah kolom, collapsed sidebar, virtualized list, atau pemilihan komponen mobile/desktop.
  • Data berubah di sela render, misalnya server memakai snapshot pada T0, lalu browser mengambil data pada T1 dan langsung merender state baru sebelum hydration selesai.

Intinya: masalahnya bukan SSR itu sendiri, melainkan ketidakcocokan sumber kebenaran pertama antara server dan klien.

Prinsip utama: state awal harus tunggal dan dapat diulang

Cara paling aman mencegah hydration drift adalah memastikan server dan klien memulai dari snapshot data yang sama, lalu menunda state yang hanya valid di browser sampai setelah hydration selesai. Ini biasanya terdiri dari empat pola:

  1. Serialisasi state awal dari hasil render server ke payload yang dipakai ulang oleh klien.
  2. Placeholder deterministik untuk nilai yang tidak stabil, misalnya waktu relatif atau ukuran layout.
  3. Client-only boundary untuk komponen yang memang bergantung pada DOM, viewport, atau API browser.
  4. Penundaan state berbasis browser sampai fase mount, bukan saat render pertama.

Jika empat pola ini konsisten diterapkan, sebagian besar mismatch pada inbox bisa dihindari tanpa mengorbankan SSR sepenuhnya.

Sumber mismatch umum dan cara menanganinya

1. Waktu relatif dan label waktu

Ini sumber drift klasik. Server merender “1 menit lalu”, tetapi saat browser hydrate labelnya sudah berubah menjadi “2 menit lalu”. Jika locale atau timezone klien juga berbeda, mismatch menjadi lebih sering.

Pola aman:

  • Render absolute timestamp yang stabil pada SSR, misalnya 2026-08-19 10:32.
  • Atau render placeholder deterministik seperti --:-- lalu ubah ke waktu relatif setelah mount.
  • Jika ingin tetap menampilkan waktu relatif sejak awal, hitung berdasarkan timestamp referensi yang sama yang ikut diserialisasi dari server.
// Contoh pola umum React/Next.js-like
function ThreadTime({ sentAt, initialNow }) {
  const [mounted, setMounted] = useState(false)
  useEffect(() => setMounted(true), [])

  if (!mounted) {
    return <time dateTime={sentAt}>{formatAbsolute(sentAt)}</time>
  }

  return <time dateTime={sentAt}>{formatRelative(sentAt, Date.now())}</time>
}

Mengapa ini bekerja? Karena HTML awal selalu sama antara server dan klien: keduanya menampilkan nilai absolut. Nilai relatif baru muncul setelah hydration selesai.

2. Status baca dan badge unread

Inbox sering menerima update cepat dari WebSocket, polling, atau sinkronisasi lintas tab. Jika server menghitung badge unread dari snapshot A, tetapi klien langsung menghitung ulang dari snapshot B sebelum hydration, angka badge bisa mismatch.

Pola aman:

  • Gunakan hasil query server sebagai initial cache atau initial state klien.
  • Jangan jalankan re-fetch agresif sebelum hydration selesai, kecuali UI area tersebut diisolasi sebagai client-only.
  • Simpan versi snapshot atau cursor agar klien tahu data mana yang menjadi basis render awal.
// Payload SSR yang diserialisasi secara umum
{
  "snapshotVersion": "inbox:cursor_784512",
  "threads": [...],
  "unreadCount": 12,
  "serverNow": 1724063520000
}

Ketika klien boot, gunakan payload ini apa adanya sebagai state awal. Setelah hydration selesai, baru proses update live dan hitung ulang badge jika perlu.

3. Urutan thread yang berubah

Sorting thread sering tidak stabil karena beberapa faktor: timestamp terbaru berubah, pesan masuk baru mendorong thread ke atas, atau klien punya aturan pinning yang tidak diterapkan identik di server.

Pola aman:

  • Tetapkan sort key yang eksplisit dan stabil.
  • Pastikan tie-breaker ada, misalnya updatedAt desc lalu threadId desc.
  • Hindari sort berbasis field turunan yang hanya tersedia di browser.
  • Jangan merge hasil fetch tambahan ke list utama sebelum hydration selesai.

Kesalahan umum di sini adalah mengandalkan urutan natural dari objek, hasil merge asinkron, atau komputasi yang memakai timezone/locale browser.

4. Preferensi lokal pengguna

Contohnya: tema gelap, compact mode, thread grouping, split pane, ukuran font, dan folder terakhir. Sebagian preferensi disimpan di localStorage atau API browser sehingga tidak tersedia di server.

Pola aman:

  • Jika preferensi penting untuk render awal, pindahkan ke cookie atau sesi agar server dapat membaca nilai yang sama.
  • Jika tidak memungkinkan, gunakan default deterministik di SSR lalu sinkronkan setelah mount.
  • Hindari membaca localStorage langsung saat render pertama.
// Anti-pattern umum
const density = localStorage.getItem('density') || 'comfortable'

// Pola lebih aman
const [density, setDensity] = useState('comfortable')
useEffect(() => {
  const saved = window.localStorage.getItem('density')
  if (saved) setDensity(saved)
}, [])

Trade-off-nya jelas: ada kemungkinan satu frame awal memakai default. Namun itu lebih aman daripada mismatch yang memaksa re-render subtree saat hydration.

5. Viewport dan layout responsif

Server biasanya tidak tahu ukuran viewport yang sebenarnya. Jika SSR memilih layout desktop tetapi klien langsung merender layout mobile, struktur DOM dapat berbeda total.

Pola aman:

  • Gunakan struktur DOM yang sama untuk SSR dan awal hydration, lalu ubah presentasi lewat CSS bila memungkinkan.
  • Isolasi komponen yang sangat bergantung pada viewport ke client-only boundary.
  • Jangan memutuskan jumlah kolom atau jenis komponen secara drastis pada render pertama berdasarkan window.innerWidth.

Untuk inbox, pendekatan yang lebih tahan mismatch adalah tetap merender list dan panel dengan struktur dasar yang sama, lalu hanya mengubah visibilitas atau proporsi melalui CSS.

6. Data yang berubah antara render server dan klien

Pada produk email, perubahan data dalam hitungan detik adalah normal. Masalah muncul ketika klien melakukan fetch kedua terlalu cepat dan hasilnya dipakai sebelum hydration menyelesaikan HTML awal.

Pola aman:

  • Gunakan snapshot SSR sebagai seed data tunggal.
  • Tunda revalidasi otomatis sampai hydration selesai.
  • Jika ada update live yang tak bisa ditunda, render indikator non-destruktif seperti “ada email baru” alih-alih langsung menyisipkan item baru ke urutan list.

Ini penting untuk menjaga DOM awal tetap konsisten. Menampilkan toast “3 email baru” lalu menerapkan update setelah interaksi pengguna sering lebih stabil daripada langsung memodifikasi daftar saat hydration.

Pola implementasi yang aman di Next.js, Nuxt, dan SvelteKit

Nama API tiap framework berbeda, tetapi prinsipnya sama: data yang dipakai SSR harus menjadi data awal klien, dan bagian yang tidak deterministik harus dipisahkan.

Serialisasi state awal

Pada semua framework SSR modern, ambil data inbox di sisi server lalu teruskan ke komponen halaman sebagai payload awal. Payload ini sebaiknya memuat:

  • Daftar thread yang ditampilkan.
  • Unread count.
  • Filter aktif, folder aktif, dan cursor pagination.
  • Timestamp referensi server jika ada komputasi relatif.
  • Versi snapshot atau ETag logis bila tersedia.

Next.js secara umum: ambil data di sisi server lalu pass sebagai props atau data server component ke client component yang menggunakan payload itu sebagai initial state.

Nuxt secara umum: ambil data pada jalur SSR, masukkan ke state yang ikut di-serialize ke payload halaman, lalu hindari fetch duplikat saat mounted kecuali memang dibutuhkan.

SvelteKit secara umum: kembalikan data dari load server-side, konsumsi langsung di komponen halaman, dan jangan mengganti data awal dengan sumber browser sebelum mount.

Aturan kuncinya: jangan punya dua sumber initial state yang saling bersaing.

Placeholder deterministik

Gunakan placeholder saat nilai final bergantung pada browser. Misalnya:

  • Waktu relatif → tampilkan waktu absolut dulu.
  • Avatar yang bergantung pada ukuran viewport → tampilkan ukuran default.
  • Panel split view → tampilkan komposisi dasar dulu.
  • Unread animation → tunda animasi sampai mount.

Placeholder deterministik lebih baik daripada menebak kondisi browser dari server dan berisiko menghasilkan DOM berbeda.

Client-only boundary

Beberapa komponen memang sebaiknya tidak ikut SSR, misalnya:

  • Virtualized thread list yang menghitung tinggi item dari DOM.
  • Composer kaya fitur yang bergantung pada selection API.
  • Panel preview dengan observer atau pengukuran elemen.
  • Komponen yang benar-benar berbeda antara mobile dan desktop.

Gunakan boundary client-only pada area yang paling berisiko, bukan seluruh halaman inbox. Dengan begitu, list utama, judul halaman, dan konten yang penting untuk SEO atau perceived performance tetap mendapat manfaat SSR.

Menunda state berbasis browser

Setiap pembacaan terhadap window, document, matchMedia, localStorage, atau ukuran elemen sebaiknya terjadi setelah mount. Jika nilai tersebut memengaruhi markup, render dulu versi fallback yang stabil.

// Pseudo-pattern lintas framework
state = {
  hydrated: false,
  sidebarCollapsed: false
}

onMount(() => {
  state.hydrated = true
  state.sidebarCollapsed = readPreferenceFromBrowser()
})

Prinsip ini sederhana tetapi efektif: SSR hanya merender hal-hal yang dapat dipastikan, sisanya diaktifkan setelah browser siap.

Strategi arsitektur untuk inbox yang cepat sekaligus stabil

Pisahkan snapshot awal dari update live

Inbox modern hampir selalu butuh update real-time. Hindari mencampur jalur render awal dengan jalur update live. Snapshot awal harus datang dari SSR dan tetap utuh sepanjang hydration. Setelah itu, baru subscription live boleh memodifikasi state.

Pola yang umum:

  1. Server merender snapshot inbox pada T0.
  2. Payload snapshot di-serialize ke HTML.
  3. Klien hydrate memakai snapshot yang sama.
  4. Setelah mount, koneksi live dibuka.
  5. Update baru diterapkan sebagai patch, bukan mengganti seluruh dataset.

Arsitektur ini menurunkan risiko drift sekaligus memudahkan debugging karena Anda tahu dengan jelas kapan UI berhenti menjadi “SSR snapshot” dan mulai menjadi “live state”.

Gunakan identitas item yang stabil

Thread harus punya key yang benar-benar stabil, biasanya threadId. Jangan gunakan index array sebagai key pada list inbox. Saat urutan berubah atau item baru masuk, key berbasis index membuat framework menempelkan state lokal ke item yang salah, yang terlihat seperti drift walau sumbernya rekonsiliasi list yang buruk.

Bedakan state data dan state presentasi

State data mencakup thread, unread count, folder aktif, pagination. State presentasi mencakup lebar panel, hover, animasi, fokus editor, atau densitas visual. Semakin banyak state presentasi ikut menentukan markup SSR, semakin besar risiko mismatch. Sebisa mungkin, minimalkan state presentasi dalam render server.

Checklist debugging hydration drift di produksi

Masalah hydration sering sulit direproduksi secara lokal karena timing dan data produksi berbeda. Gunakan checklist ini:

Gejala yang umum terlihat

  • Peringatan hydration mismatch di console browser.
  • Teks waktu berubah sesaat setelah load.
  • Unread badge meloncat dari satu angka ke angka lain.
  • Thread pindah urutan atau item terpilih berubah sendiri.
  • Layout inbox desktop/mobile berkedip saat awal load.
  • Interaksi pertama terasa aneh karena subtree di-render ulang.

Checklist investigasi

  1. Bandingkan HTML SSR dengan DOM setelah hydration. Cari teks, atribut, dan struktur yang berubah.
  2. Log snapshot awal yang dirender server, termasuk timestamp referensi, unread count, dan sort key.
  3. Pastikan fetch klien tidak menimpa state terlalu awal. Audit hook revalidasi, polling, dan subscription.
  4. Audit semua pembacaan API browser pada render awal: viewport, localStorage, timezone, locale, media query.
  5. Periksa key pada list thread. Key yang tidak stabil sering tampak seperti mismatch data.
  6. Uji dengan tab ganda dan update real-time. Banyak bug inbox hanya muncul saat state berubah dari sumber eksternal.
  7. Uji timezone dan locale berbeda. Label waktu dan formatting sering lolos pada mesin developer yang seragam.
  8. Periksa sorting dan tie-breaker. Jika dua item punya nilai sort sama, urutan bisa berbeda.

Teknik observabilitas yang membantu

  • Sisipkan ID snapshot pada payload dan kirim ke logging klien saat terjadi mismatch.
  • Log event “hydration started”, “hydration finished”, dan “live updates attached”.
  • Hitung jumlah patch DOM besar atau render ulang subtree inbox setelah boot.
  • Rekam contoh payload inbox yang memicu drift untuk replay lokal.

Debugging akan jauh lebih mudah jika Anda memperlakukan hydration sebagai fase eksplisit dalam lifecycle aplikasi, bukan detail internal framework semata.

Trade-off: SEO, TTFB, dan UX

Mencegah hydration drift tidak berarti semua hal harus di-SSR-kan. Ada trade-off yang perlu dipilih per komponen.

Kapan SSR penuh layak dipertahankan

  • Daftar inbox utama perlu tampil cepat dan stabil.
  • Konten awal penting untuk indexing atau preview sosial.
  • Pengguna sensitif terhadap loading kosong.

Pada kasus ini, gunakan SSR untuk struktur utama dan data awal yang stabil, lalu tunda bagian non-deterministik.

Kapan client-only lebih masuk akal

  • Komponen sangat bergantung pada DOM atau viewport.
  • State berubah sangat cepat sehingga snapshot server cepat basi.
  • Nilai SEO komponen rendah, misalnya panel preview interaktif atau widget sekunder.

Client-only menurunkan risiko mismatch, tetapi bisa menaikkan waktu sampai komponen benar-benar interaktif dan mengurangi manfaat HTML awal.

Implikasi terhadap TTFB

SSR yang lebih kaya data bisa meningkatkan TTFB karena server harus menyiapkan snapshot inbox yang konsisten. Namun sering kali ini tetap layak jika hasilnya menurunkan re-render besar di klien dan membuat load awal terasa lebih stabil. Sebaliknya, terlalu banyak menunda ke klien bisa mempercepat TTFB tetapi membuat pengguna melihat skeleton atau layout yang berubah-ubah.

Implikasi terhadap UX

Dari sisi UX, stabilitas visual sering lebih penting daripada memaksakan semua state final muncul seketika. Pengguna inbox lebih menghargai list yang tidak meloncat, badge yang konsisten, dan panel yang tidak berkedip dibanding pembaruan super cepat yang merusak kepercayaan pada UI.

Aturan praktis: jika sebuah nilai tidak dapat dijamin sama antara server dan klien pada render pertama, jangan jadikan ia penentu markup awal.

Pola implementasi minimal yang sering cukup

Untuk banyak aplikasi mirip klien email, kombinasi berikut sudah menutup mayoritas masalah:

  1. SSR merender snapshot inbox lengkap dengan thread, unread count, folder aktif, dan timestamp referensi.
  2. Klien memakai payload itu sebagai state awal tanpa fetch pengganti saat boot.
  3. Waktu relatif, viewport-specific UI, dan preferensi dari browser ditunda sampai mount.
  4. Komponen sangat interaktif dibungkus client-only boundary.
  5. Update live baru diaktifkan setelah hydration selesai.
  6. Sorting memakai key stabil dengan tie-breaker eksplisit.

Pendekatan ini sejalan dengan kebutuhan produk email modern: cepat pada load pertama, stabil saat di-hydrate, lalu kaya state setelah browser siap. Itulah inti SSR stabil untuk klien email—bukan sekadar mengirim HTML lebih cepat, tetapi memastikan inbox yang dilihat pengguna tidak bergeser dari fondasi render pertamanya.