Render mismatch pada Spring Boot + Thymeleaf biasanya terjadi ketika server mengirim HTML awal yang tampak benar, tetapi beberapa milidetik kemudian JavaScript mengubah bagian tertentu berdasarkan cookie, localStorage, waktu lokal, atau state login di browser. Akibatnya, pengguna melihat UI yang berkedip, nilai form berubah sendiri, badge login salah sesaat, atau tema gelap-terang tidak konsisten.

Masalah ini umum pada SSR parsial: halaman dirender di server dengan Thymeleaf, lalu sebagian state “disempurnakan” di sisi klien. Bug-nya membingungkan karena HTML final di DevTools sering terlihat benar, tetapi pengalaman pengguna tetap salah. Kuncinya adalah menyelaraskan sumber kebenaran antara server dan browser, atau menunda render bagian yang memang hanya bisa ditentukan di klien.

Apa yang Dimaksud Render Mismatch pada SSR Parsial?

Pada aplikasi Spring Boot + Thymeleaf, server biasanya menghasilkan HTML awal berdasarkan model dari controller. Setelah halaman dimuat, JavaScript dapat membaca data tambahan dari browser lalu memodifikasi DOM. Render mismatch terjadi ketika dua proses ini memakai asumsi state yang berbeda.

Contoh yang sering terjadi:

  • Nilai form: server mengisi input dari session, lalu JavaScript menggantinya dari localStorage.
  • Badge login: server menampilkan “Guest”, tetapi script kemudian mendeteksi token klien dan mengubahnya menjadi “Hi, Budi”.
  • Tema: server selalu render tema terang, lalu JavaScript mengaktifkan tema gelap dari preferensi lokal.
  • Elemen berbasis waktu: server menghitung status “Tutup”, tetapi waktu lokal browser membuat script menggantinya menjadi “Buka”.
  • Konten berbasis cookie: server belum melihat cookie tertentu atau memakainya dengan aturan berbeda dari script.

Secara visual, mismatch biasanya terlihat sebagai flicker, content jump, atau perubahan teks/kelas CSS beberapa saat setelah halaman tampil.

Gejala yang Perlu Dikenali

1. UI benar sesaat lalu berubah

Ini gejala paling umum. Misalnya badge status login menampilkan Masuk, lalu berubah ke Guest atau sebaliknya.

2. Nilai input tidak sesuai data yang dikirim

Pengguna melihat field sudah terisi, tetapi saat submit ternyata nilai aktual berasal dari script lain, bukan dari render awal.

3. Tema halaman berkedip

Halaman pertama kali tampil terang lalu berubah menjadi gelap setelah script membaca preferensi tema dari browser.

4. Sulit direproduksi secara konsisten

Bug sering muncul hanya di perangkat tertentu, saat koneksi lambat, atau ketika cache/browser state tertentu aktif. Ini membuat masalah tampak acak padahal akar penyebabnya tetap sama: state awal server dan state klien tidak sinkron.

Root Cause: Dua Sumber Kebenaran yang Berbeda

Masalah inti hampir selalu berasal dari salah satu pola berikut:

  • Server dan klien menghitung state dari sumber data berbeda. Contoh: server memakai session Spring Security, klien memakai token di localStorage.
  • Server tidak punya akses ke state browser. Contoh: preferensi tema, draft form lokal, zona waktu pengguna, atau cookie yang dibaca script pihak ketiga.
  • Perhitungan dilakukan dua kali dengan aturan berbeda. Contoh: server menganggap toko tutup berdasarkan waktu UTC tertentu, klien menghitung berdasarkan waktu lokal browser.
  • DOM diubah terlalu cepat setelah SSR. HTML awal sempat terlihat, lalu script langsung mengganti bagian penting sebelum pengguna sempat berinteraksi.

Jika satu bagian UI bergantung pada data yang hanya diketahui browser, maka SSR penuh untuk bagian itu sering tidak mungkin akurat. Dalam kasus seperti ini, memaksa server menebak state awal justru memicu mismatch.

Contoh Bug yang Bermasalah

Kasus 1: Badge login dari session vs token klien

Controller merender status login dari session:

@Controller
public class HomeController {

    @GetMapping("/")
    public String home(Model model, Principal principal) {
        model.addAttribute("loggedIn", principal != null);
        model.addAttribute("displayName", principal != null ? principal.getName() : "Guest");
        return "home";
    }
}

Template Thymeleaf:

<div id="user-badge"
     th:data-logged-in="${loggedIn}"
     th:data-display-name="${displayName}">
  <span th:if="${loggedIn}" th:text="'Hi, ' + ${displayName}">Hi, User</span>
  <span th:unless="${loggedIn}">Guest</span>
</div>

Lalu JavaScript mencoba “mengoreksi” badge berdasarkan token di browser:

const token = localStorage.getItem('auth_token');
const badge = document.getElementById('user-badge');

if (token) {
  badge.textContent = 'Hi, Budi';
} else {
  badge.textContent = 'Guest';
}

Ini bermasalah karena ada dua definisi login:

  • Server: login berdasarkan Principal/session.
  • Klien: login berdasarkan token di localStorage.

Jika token stale, session expired, atau mekanisme autentikasi tidak identik, pengguna akan melihat badge berubah setelah halaman tampil.

Kasus 2: Tema dari localStorage

Template awal selalu merender tema terang:

<body class="theme-light">
  ...
  <script>
    const theme = localStorage.getItem('theme');
    if (theme === 'dark') {
      document.body.classList.remove('theme-light');
      document.body.classList.add('theme-dark');
    }
  </script>
</body>

Secara teknis script ini bekerja, tetapi pengguna melihat flash tema terang sebelum tema gelap diterapkan. Ini bukan error JavaScript, melainkan masalah urutan render.

Kasus 3: Nilai form ditimpa state lokal

<input id="city" name="city" th:value="${profile.city}" />
<script>
  const savedCity = localStorage.getItem('draft_city');
  if (savedCity) {
    document.getElementById('city').value = savedCity;
  }
</script>

Jika pengguna membuka halaman edit profil, server mengirim nilai resmi dari database, tetapi script langsung menimpa field dengan draft lama. Hasilnya membingungkan: UI menampilkan data berbeda dari yang diharapkan backend.

Cara Reproduksi Agar Mudah Di-debug

Masalah render mismatch sering lebih mudah terlihat jika Anda memperlambat kondisi render.

Langkah reproduksi sederhana

  1. Buat satu elemen yang dirender dari Thymeleaf, misalnya badge login atau tema.
  2. Tambahkan script yang mengubah elemen itu berdasarkan localStorage atau waktu lokal.
  3. Aktifkan throttling jaringan di DevTools agar transisi lebih mudah terlihat.
  4. Gunakan Disable cache saat reload.
  5. Rekam dengan tab Performance atau lihat perubahan DOM di Elements.

Untuk debugging manual, tambahkan log yang membedakan state server dan klien:

<div id="theme-root" th:data-server-theme="${theme}"></div>
<script>
  const root = document.getElementById('theme-root');
  console.log('Server theme:', root.dataset.serverTheme);
  console.log('Client theme:', localStorage.getItem('theme'));
</script>

Dengan cara ini, Anda bisa melihat mismatch sebagai perbedaan data, bukan sekadar gejala visual.

Strategi Perbaikan yang Praktis

1. Tentukan satu sumber kebenaran per state

Aturan paling penting: satu state penting sebaiknya punya satu sumber kebenaran utama.

  • Jika status login ditentukan backend, jangan jadikan localStorage sebagai penentu UI utama.
  • Jika tema sepenuhnya preferensi browser, jangan paksa server merender nilai final tanpa data yang memadai.
  • Jika field form berasal dari database, draft lokal harus diterapkan dengan aturan yang jelas, bukan otomatis menimpa semuanya.

Pola ini mengurangi konflik antara HTML awal dan script pasca-load.

2. Bootstrap state server ke klien lewat data-* atau JSON

Jika JavaScript perlu melanjutkan state yang sama dengan yang dipakai Thymeleaf, kirim state itu secara eksplisit dari server.

Contoh dengan data-*:

<div id="user-badge"
     th:data-logged-in="${loggedIn}"
     th:data-display-name="${displayName}">
  <span th:text="${loggedIn} ? 'Hi, ' + ${displayName} : 'Guest'"></span>
</div>
<script>
  const badge = document.getElementById('user-badge');
  const serverLoggedIn = badge.dataset.loggedIn === 'true';
  const serverName = badge.dataset.displayName;

  // Gunakan state server sebagai baseline, bukan menebak ulang.
  console.log({ serverLoggedIn, serverName });
</script>

Untuk state yang lebih kompleks, gunakan bootstrap JSON:

<script type="application/json" id="bootstrap-state">
{
  "loggedIn": [[${loggedIn}]],
  "displayName": "[[${displayName}]]"
}
</script>
<script>
  const state = JSON.parse(document.getElementById('bootstrap-state').textContent);
  console.log(state);
</script>

Intinya, JavaScript tidak perlu menghitung ulang sesuatu yang sebenarnya sudah diketahui server.

3. Tunda render bagian yang memang hanya bisa diketahui di klien

Jika state final benar-benar hanya bisa diketahui di browser, jangan render versi server yang “palsu” lalu langsung diganti. Lebih baik tampilkan placeholder netral sampai state siap.

Contoh untuk tema atau komponen berbasis localStorage:

<style>
  .client-only-pending { visibility: hidden; }
</style>

<body class="client-only-pending">
  ...
  <script>
    const theme = localStorage.getItem('theme') || 'light';
    document.body.classList.add(theme === 'dark' ? 'theme-dark' : 'theme-light');
    document.body.classList.remove('client-only-pending');
  </script>
</body>

Trade-off: pendekatan ini mengurangi flicker, tetapi ada biaya berupa penundaan kecil sebelum bagian tertentu terlihat. Cocok untuk elemen yang sangat sensitif terhadap perubahan visual, tetapi jangan dipakai untuk menyembunyikan seluruh halaman tanpa alasan kuat.

4. Gunakan script inisialisasi sedini mungkin untuk tema

Kasus tema adalah contoh klasik. Agar tidak terjadi flash, jalankan script penentu tema sebelum CSS utama selesai memengaruhi tampilan sebanyak mungkin, misalnya di bagian atas dokumen.

<head>
  <script>
    (function() {
      try {
        var theme = localStorage.getItem('theme');
        if (theme === 'dark') {
          document.documentElement.classList.add('theme-dark');
        } else {
          document.documentElement.classList.add('theme-light');
        }
      } catch (e) {
        document.documentElement.classList.add('theme-light');
      }
    })();
  </script>
</head>

Pendekatan ini bekerja karena class tema sudah diterapkan sebelum browser menyusun tampilan final. Namun, pastikan fallback tetap aman jika akses ke storage gagal.

5. Jangan menimpa form secara agresif

Untuk draft form, gunakan aturan yang eksplisit, misalnya hanya pulihkan draft jika field server kosong, atau minta konfirmasi pengguna.

const input = document.getElementById('city');
const savedCity = localStorage.getItem('draft_city');

if (savedCity && !input.value) {
  input.value = savedCity;
}

Alternatif yang lebih aman:

  • Tampilkan tombol Pulihkan draft.
  • Simpan stempel waktu draft dan bandingkan dengan data server.
  • Jangan terapkan draft ke field yang sensitif tanpa persetujuan pengguna.

6. Hindari logika waktu yang dihitung dua kali dengan zona berbeda

Jika elemen berbasis waktu penting untuk bisnis, pilih salah satu:

  • Server authoritative: server mengirim status final dan waktu referensi, klien hanya menampilkan.
  • Client authoritative: server mengirim data mentah dan zona/waktu referensi yang cukup, lalu klien menghitung secara konsisten.

Yang perlu dihindari adalah server dan browser sama-sama menghitung “buka/tutup” dengan asumsi berbeda.

Contoh Perbaikan End-to-End

Misalkan Anda punya badge login dan sapaan yang sebelumnya diubah dari localStorage. Perbaikannya adalah menjadikan backend sebagai sumber kebenaran untuk UI login.

Controller

@Controller
public class HomeController {

    @GetMapping("/")
    public String home(Model model, Principal principal) {
        boolean loggedIn = principal != null;
        model.addAttribute("loggedIn", loggedIn);
        model.addAttribute("displayName", loggedIn ? principal.getName() : "Guest");
        return "home";
    }
}

Template Thymeleaf

<div id="user-badge"
     th:data-logged-in="${loggedIn}"
     th:data-display-name="${displayName}">
  <span th:text="${loggedIn} ? 'Hi, ' + ${displayName} : 'Guest'"></span>
</div>

JavaScript

const badge = document.getElementById('user-badge');
const loggedIn = badge.dataset.loggedIn === 'true';
const displayName = badge.dataset.displayName;

// Misalnya hanya untuk analytics atau interaksi tambahan,
// bukan untuk menimpa status login utama.
if (loggedIn) {
  console.log('User session aktif:', displayName);
}

Pola ini sederhana tetapi efektif: HTML awal dan script memakai state yang sama. Tidak ada lagi “koreksi” UI berdasarkan logika klien yang berbeda.

Kapan Memakai data-* dan Kapan JSON Bootstrap?

Pilih data-* jika:

  • State kecil dan melekat pada satu elemen.
  • Nilai berupa string, boolean sederhana, atau identifier.
  • JavaScript yang memakai state hanya terkait elemen tersebut.

Pilih JSON bootstrap jika:

  • State dipakai banyak komponen.
  • Struktur data lebih kompleks.
  • Anda ingin satu titik inisialisasi yang konsisten antara SSR dan script klien.

Catatan praktis: jangan menyuntikkan data sensitif ke HTML hanya demi kenyamanan bootstrap state. Semua data di markup dapat dibaca pengguna.

Checklist Debugging Render Mismatch

  • Bandingkan state server vs state klien. Apakah keduanya berasal dari sumber yang sama?
  • Cari elemen yang berubah setelah load. Gunakan DevTools untuk melihat perubahan teks, atribut, class, dan value input.
  • Periksa urutan script. Apakah script inisialisasi berjalan setelah elemen sempat dirender dengan state yang salah?
  • Audit penggunaan localStorage, sessionStorage, cookie, dan waktu lokal. Ini sumber mismatch yang paling umum.
  • Periksa apakah ada dua mekanisme autentikasi UI. Session backend dan token frontend sering tidak benar-benar sinkron.
  • Uji dengan cache dimatikan dan throttling aktif. Flicker yang tidak terlihat pada mesin cepat sering muncul jelas saat kondisi diperlambat.
  • Log nilai sebelum dan sesudah mutasi DOM. Jangan hanya melihat hasil visual.
  • Pastikan draft form tidak otomatis menimpa data resmi.
  • Untuk tema, jalankan inisialisasi sedini mungkin.
  • Jika state hanya diketahui klien, pertimbangkan placeholder netral.

Kesalahan Umum yang Sering Terjadi

Menganggap masalahnya ada di Thymeleaf

Sering kali Thymeleaf bekerja benar. Yang salah adalah asumsi bahwa HTML server harus langsung cocok dengan state browser yang belum diketahui server.

Memperbaiki gejala, bukan sumber state

Contohnya menambah timeout sebelum script berjalan agar flicker “berkurang”. Ini tidak menyelesaikan mismatch, hanya menyembunyikannya.

Mencampur state otoritatif dan state kosmetik

Status login, izin akses, dan nilai bisnis penting sebaiknya tidak diperlakukan sama dengan preferensi tampilan seperti tema.

Menimpa DOM tanpa kontrak data yang jelas

Jika script memodifikasi elemen SSR, harus jelas data awalnya dari mana dan kapan boleh berubah.

Penutup

Bug UI pada Spring Boot + Thymeleaf: debug render mismatch pada SSR parsial hampir selalu berakar pada ketidaksinkronan state antara server dan browser. Gejalanya bisa tampak sepele—badge login berubah, tema berkedip, nilai form tidak konsisten—tetapi dampaknya besar terhadap kepercayaan pengguna dan kemudahan debugging.

Pendekatan yang paling aman adalah:

  • tetapkan satu sumber kebenaran per state,
  • bootstrap state server ke klien bila perlu,
  • tunda render bagian yang memang hanya bisa diputuskan di browser,
  • dan audit mutasi DOM yang terjadi setelah HTML awal tampil.

Jika Anda menerapkan pola ini secara konsisten, flicker dan mismatch akan jauh lebih mudah dicegah daripada diburu setelah bug sampai ke produksi.