Flash UI setelah resume sering terlihat sebagai komponen yang “meloncat”, teks yang berubah sesaat, status login yang berkedip, atau layout yang berganti tepat setelah tab kembali aktif. Akar masalahnya sederhana: markup SSR yang dikirim server dibuat dari state pada waktu tertentu, tetapi saat browser melanjutkan eksekusi setelah suspend, resume, atau tab kembali terlihat, state di sisi klien bisa sudah berbeda.

Konteks ini makin relevan karena banyak tim mulai meninjau ulang apa saja yang tetap berada di memori setelah perangkat suspend. Namun untuk frontend, dampak yang paling cepat terasa biasanya bukan isu forensik memori, melainkan lifecycle resume yang memicu state usang, refresh sesi, dan revalidasi data pada waktu yang tidak sinkron. Jika tidak ditangani, hasilnya adalah flash, hydration mismatch, dan pengalaman UI yang tidak stabil.

Mengapa gejala ini muncul setelah resume atau tab aktif lagi?

Pada aplikasi SSR, server merender HTML berdasarkan state pada saat request diproses. Beberapa saat kemudian, browser melakukan hydrasi: memasang event handler, membangun tree komponen, lalu menyelaraskan UI dengan state di klien. Masalah muncul ketika state klien saat hydrasi tidak identik dengan asumsi state saat SSR.

Setelah laptop resume atau tab kembali aktif, beberapa hal bisa berubah hampir bersamaan:

  • token sesi di-refresh otomatis,
  • cache client memuat data baru,
  • localStorage dibaca ulang atau dipakai untuk bootstrap state,
  • jam sistem atau perhitungan berbasis waktu menghasilkan output berbeda,
  • event seperti visibilitychange atau pageshow memicu refetch.

Jika perubahan itu terjadi sebelum atau selama hydrasi, browser melihat bahwa hasil render klien tidak cocok dengan HTML awal. Tergantung framework, gejalanya bisa berupa peringatan mismatch, subtree yang dirender ulang, atau UI yang tampak berkedip.

Sumber mismatch yang paling umum

1. Cache client lebih baru daripada HTML SSR

Kasus klasik: server merender daftar notifikasi kosong, tetapi cache query di browser masih menyimpan 3 notifikasi dari sesi sebelumnya. Begitu aplikasi aktif, komponen di klien langsung membaca cache dan menampilkan isi baru sebelum revalidasi selesai. Hasilnya, pengguna melihat perubahan mendadak dari HTML awal ke state cache.

Ini sering terjadi pada library data fetching yang mempertahankan cache lintas navigasi atau lintas tab. Bukan bug di library-nya, tetapi masalah urutan sumber kebenaran saat booting dan resume.

2. Bootstrap state dari localStorage atau sessionStorage

Menyimpan preferensi UI, draft form, cart, atau status auth ringan di localStorage memang praktis. Masalahnya, data ini hanya tersedia di browser, tidak saat SSR. Jika komponen merender satu tampilan saat SSR lalu langsung mengganti output berdasarkan localStorage ketika mount, Anda mendapat flash.

Contoh umum:

  • SSR menampilkan guest menu, lalu klien membaca token lokal dan mengganti ke user menu.
  • SSR menampilkan tema default terang, lalu klien menerapkan tema gelap dari storage.
  • SSR merender panel tertutup, lalu store lokal membuka panel begitu aplikasi aktif.

3. In-memory store bertahan lebih lama dari asumsi SSR

Store di memori browser tidak ikut dibangun server. Setelah tab atau aplikasi aktif lagi, store bisa memuat state hasil interaksi sebelumnya, sementara HTML SSR dari navigasi baru masih berdasarkan data server yang lama. Jika store dipakai sebagai sumber render utama tanpa sinkronisasi dengan payload SSR, mismatch mudah terjadi.

4. Render nondeterministik: waktu, locale, random, ukuran viewport

Render SSR harus deterministik terhadap input yang sama. Jika output tergantung pada nilai yang berubah antara server dan klien, mismatch bisa terjadi bahkan tanpa resume. Resume hanya memperbesar peluangnya karena jeda waktu membuat perbedaan makin terlihat.

Pola yang sering bermasalah:

  • Date.now(), countdown, “5 menit lalu”, jam lokal,
  • Math.random() untuk key, urutan, atau variasi UI,
  • akses langsung ke window atau ukuran viewport saat render,
  • perbedaan locale/timezone server dan browser.

5. Auth atau session refresh saat tab kembali aktif

Banyak aplikasi me-refresh token atau mengecek status sesi saat visibilitychange atau fokus window. Jika header, tombol aksi, atau data sensitif bergantung pada status auth, UI bisa berubah tepat setelah resume. Ini bukan sekadar kosmetik: jika konten sensitif sempat tampil dari state lama, lalu menghilang setelah validasi sesi, Anda punya masalah UX dan potensi kebocoran informasi visual sesaat.

6. Event lifecycle browser: visibilitychange dan pageshow

Dua event ini sering menjadi pemicu revalidasi:

  • visibilitychange: terpanggil saat tab menjadi terlihat atau tersembunyi.
  • pageshow: relevan saat halaman dipulihkan dari navigasi tertentu atau back-forward cache.

Masalah muncul ketika handler event langsung memodifikasi store yang juga dipakai saat hydrasi awal, tanpa fase transisi yang jelas. Akibatnya, SSR belum sempat “ketemu” dengan state klien yang benar sebelum UI berubah lagi.

Prinsip utama: tentukan satu sumber kebenaran untuk fase awal

Untuk mencegah flash UI setelah resume, pilih strategi sinkronisasi yang eksplisit. Jangan biarkan komponen bebas memilih antara HTML SSR, cache lama, storage lokal, dan hasil refresh sesi pada saat yang sama.

Prinsip yang paling aman:

  1. SSR payload menjadi baseline awal render.
  2. State klien yang berpotensi lebih baru tidak langsung mengubah area sensitif sebelum validasi selesai.
  3. Revalidasi setelah resume dilakukan sebagai fase terpisah dan terukur.
  4. Bagian UI yang sensitif boleh ditunda render-nya sampai status final diketahui.

Jika Anda harus memilih antara sedikit penundaan render dan konten yang berkedip atau salah sesaat, untuk area sensitif hampir selalu lebih aman menunda render.

Pola mitigasi yang efektif

1. Pisahkan state “initial”, “stale”, dan “validated”

Jangan gunakan satu boolean seperti isAuthenticated untuk semua fase. Buat status yang lebih eksplisit, misalnya:

  • unknown: belum diverifikasi setelah resume,
  • stale: ada state lama, tetapi belum divalidasi,
  • valid: sudah diverifikasi dan aman dipakai untuk render,
  • invalid: sesi tidak valid atau data harus dibersihkan.

Dengan model ini, UI tidak dipaksa menampilkan “guest” atau “user” terlalu cepat. Anda bisa menampilkan skeleton, placeholder netral, atau menyembunyikan komponen sensitif sampai status menjadi valid atau invalid.

2. Tunda render bagian sensitif sampai revalidasi selesai

Ini penting untuk komponen seperti:

  • nama pengguna, email, avatar,
  • saldo, invoice, daftar dokumen privat,
  • aksi yang bergantung pada role/permission,
  • notifikasi internal atau pesan privat.

Komponen non-sensitif seperti kerangka layout, navigasi umum, atau konten publik tetap bisa dirender dari SSR agar halaman terasa cepat. Jadi yang ditunda hanya area yang benar-benar rawan salah.

3. Jangan bootstrap render langsung dari localStorage untuk data yang menentukan identitas UI

localStorage cocok untuk preferensi yang tidak sensitif dan tidak memengaruhi integritas data utama, seperti tema atau panel yang terbuka. Untuk status auth, role, atau data yang menentukan konten mana yang boleh terlihat, gunakan hasil validasi server atau endpoint sesi yang ringan.

Jika tetap perlu membaca storage, gunakan hasilnya sebagai hint, bukan sumber final render. Misalnya, storage boleh menentukan bahwa aplikasi perlu segera me-refresh sesi, tetapi tidak boleh langsung membuka dashboard privat sebelum verifikasi selesai.

4. Hindari render nondeterministik pada SSR

Jika nilai hanya diketahui akurat di browser, jangan jadikan nilai itu bagian dari output SSR yang harus cocok persis. Beberapa teknik yang lebih aman:

  • render placeholder stabil saat SSR, lalu isi nilai dinamis setelah mount,
  • format waktu relatif di klien setelah hydrasi, bukan saat SSR,
  • buat ID stabil dari data, bukan dari Math.random(),
  • pisahkan komponen yang bergantung pada viewport agar tidak menentukan struktur HTML awal.

5. Debounce atau orkestrasi revalidasi saat resume

Setelah resume, beberapa event bisa datang hampir bersamaan: focus, visibility change, reconnect, dan timer yang tertunda. Jika semuanya memicu fetch dan update store, UI jadi sangat mudah goyah. Solusinya adalah satu orkestrator resume yang:

  • mendeteksi bahwa aplikasi baru kembali aktif,
  • menjalankan revalidasi penting sekali saja,
  • mengunci area sensitif selama proses berjalan,
  • membuka render normal setelah hasil final diterima.

Contoh pseudo-code: gate render saat resume

Pola berikut bisa diterapkan lintas framework. Fokusnya adalah memisahkan fase resume dari render biasa.

state = {
  sessionStatus: 'unknown',   // unknown | validating | valid | invalid
  profile: null,
  lastResumeAt: 0
}

function onAppStart(ssrPayload) {
  hydrateStoreFromSSR(ssrPayload)
  state.sessionStatus = ssrPayload.sessionKnown ? 'valid' : 'unknown'
  attachResumeListeners()
}

function attachResumeListeners() {
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
      handleResume()
    }
  })

  window.addEventListener('pageshow', () => {
    handleResume()
  })
}

let resumeInFlight = null

async function handleResume() {
  if (resumeInFlight) return resumeInFlight

  state.lastResumeAt = Date.now()
  state.sessionStatus = 'validating'

  resumeInFlight = (async () => {
    try {
      const session = await fetchSession()

      if (!session.valid) {
        clearSensitiveClientState()
        state.profile = null
        state.sessionStatus = 'invalid'
        return
      }

      const freshProfile = await fetchProfile()
      state.profile = freshProfile
      state.sessionStatus = 'valid'
    } catch (e) {
      // Pilihan kebijakan: tetap tampilkan stale non-sensitif atau fail closed
      state.sessionStatus = 'invalid'
    } finally {
      resumeInFlight = null
    }
  })()

  return resumeInFlight
}

function SensitivePanel() {
  if (state.sessionStatus === 'unknown' || state.sessionStatus === 'validating') {
    return renderSkeleton()
  }

  if (state.sessionStatus === 'invalid') {
    return renderLoggedOutState()
  }

  return renderProfile(state.profile)
}

Mengapa pola ini bekerja? Karena Anda tidak lagi membiarkan render mengambil keputusan dari state campuran. Komponen sensitif hanya membaca status yang sudah dinormalisasi oleh satu alur resume.

Penerapan umum di Next.js, Nuxt, dan SvelteKit

Nama API dan detail implementasi berbeda, tetapi pola arsitekturnya sama: SSR memberi payload awal, lalu klien memiliki lapisan revalidasi saat resume.

Next.js

  • Simpan data SSR awal sebagai baseline untuk komponen yang ter-hydrate.
  • Jangan langsung mengganti header/auth UI dari localStorage pada render pertama.
  • Buat hook resume global di root layout atau provider aplikasi untuk menangani visibilitychange dan pageshow.
  • Jika memakai cache query, pertimbangkan agar area sensitif tidak langsung membaca cache lama sebelum revalidasi auth selesai.
  • Untuk komponen yang hanya aman dirender di klien setelah validasi, tampilkan fallback stabil terlebih dulu.

Nuxt

  • Gunakan payload SSR sebagai sumber data awal yang konsisten.
  • Pisahkan state universal dan state browser-only dengan hati-hati.
  • Jika ada plugin client-only yang mengisi store dari storage, jangan biarkan plugin itu langsung menentukan tampilan sensitif tanpa validasi.
  • Tempatkan mekanisme resume pada plugin atau composable global agar semua komponen mengikuti status yang sama.

SvelteKit

  • Pertahankan pemisahan jelas antara data hasil load dan state browser-only.
  • Jika store klien meng-overwrite data hasil SSR saat mount, beri fase gate sebelum komponen privat ditampilkan.
  • Gunakan event resume untuk memicu invalidasi atau refetch secara terkoordinasi, bukan tersebar per-komponen.

Intinya, apa pun framework-nya, hindari skenario ini: SSR menghasilkan A, store browser menyimpan B, event resume memicu C, dan komponen bebas merender berdasarkan siapa yang menang balapan.

Kapan perlu menunda render bagian sensitif?

Menunda render tidak harus diterapkan ke seluruh halaman. Terapkan jika salah satu kondisi berikut terjadi:

  • konten bergantung pada status auth atau role yang bisa berubah saat resume,
  • data berasal dari cache lokal yang bisa lebih lama atau lebih baru daripada SSR,
  • ada kemungkinan sesi kadaluarsa saat perangkat suspend,
  • komponen menampilkan data privat yang tidak boleh sempat terlihat salah,
  • komponen sangat rentan terhadap mismatch karena menggunakan data browser-only saat render awal.

Untuk konten publik atau dekoratif, flash kecil mungkin bisa ditoleransi. Untuk konten privat, gunakan kebijakan fail closed: lebih baik placeholder sebentar daripada menampilkan data yang belum dipastikan valid.

Checklist diagnosis dan debug

Checklist cepat

  1. Bandingkan output SSR awal dengan state store saat mount pertama.
  2. Periksa apakah komponen membaca localStorage atau window saat render, bukan setelah mount.
  3. Lihat apakah event visibilitychange, pageshow, focus, atau reconnect memicu refetch ganda.
  4. Audit komponen yang menampilkan waktu relatif, random value, atau data berbasis locale.
  5. Periksa apakah cache query lama langsung dipakai sebelum validasi auth selesai.
  6. Uji skenario: login, suspend laptop, tunggu sesi berubah, resume, lalu amati header dan panel privat.

Instrumentasi yang berguna

Tambahkan log transisi state, bukan hanya log error. Contoh:

function transition(name, next) {
  console.debug('[ui-state]', name, {
    from: state[name],
    to: next,
    at: new Date().toISOString(),
    visibility: document.visibilityState
  })
  state[name] = next
}

Dengan log seperti ini, Anda bisa melihat urutan nyata: apakah store dari storage muncul dulu, lalu auth refresh, lalu query cache menimpa lagi. Banyak bug resume sebenarnya adalah bug orkestrasi event.

Reproduksi yang lebih deterministik

  • Login sebagai user dengan data privat yang mudah dikenali.
  • Buka DevTools dan aktifkan logging state.
  • Suspend perangkat atau biarkan tab tidak aktif cukup lama.
  • Jika memungkinkan, buat sesi di server menjadi kadaluarsa atau berubah role.
  • Resume perangkat dan perhatikan urutan event serta perubahan DOM.

Jangan hanya mengandalkan refresh normal. Bug ini sering tidak muncul saat full reload, tetapi muncul saat lifecycle resume dan back-forward navigation.

Kesalahan implementasi yang sering terjadi

  • Menganggap mismatch hanya masalah kosmetik. Pada area sensitif, ini bisa berarti data lama sempat muncul sesaat.
  • Mencampur auth state dari cookie server dan token lokal tanpa prioritas jelas.
  • Merender dari cache lama lalu berharap refetch akan “memperbaiki sendiri”. Pengguna tetap melihat flash.
  • Memasang listener resume di banyak komponen. Ini mudah memicu fetch paralel dan update saling timpa.
  • Menggunakan waktu saat ini dalam output SSR. Bahkan perbedaan beberapa detik bisa menghasilkan teks berbeda saat hydrasi.

Strategi keputusan: render segera, render stale, atau tahan dulu?

Pilih berdasarkan sensitivitas data dan biaya UX:

  • Render segera: untuk konten publik yang tidak masalah jika berubah setelah revalidasi.
  • Render stale dengan penanda: untuk data non-kritis yang masih berguna walau belum segar, misalnya daftar umum atau statistik ringan.
  • Tahan dulu sampai validasi selesai: untuk identitas pengguna, permission, data finansial, dokumen privat, dan aksi yang tidak boleh salah tampil.

Jika ragu, gunakan aturan sederhana: semakin sensitif data, semakin kecil toleransi terhadap state stale saat resume.

Penutup

Cegah flash UI setelah resume dengan menganggap resume sebagai fase lifecycle yang berbeda, bukan sekadar kelanjutan dari render biasa. HTML SSR adalah snapshot lama; browser saat aktif kembali membawa cache, storage, store memori, dan event yang bisa mengubah state dengan cepat. Tanpa sumber kebenaran yang jelas, mismatch hampir pasti terjadi.

Solusi yang paling konsisten adalah: jadikan SSR payload sebagai baseline, orkestrasi revalidasi resume di satu tempat, hindari bootstrap identitas UI dari storage lokal, dan tunda render bagian sensitif sampai validasi selesai. Dengan pendekatan ini, Anda bukan hanya menghilangkan komponen yang “meloncat”, tetapi juga mengurangi risiko konten privat tampil sesaat dari state yang sudah tidak valid.