GraphQL SSR sering memunculkan masalah yang terlihat sepele tetapi mengganggu: server berhasil mengirim HTML, namun saat JavaScript aktif di browser, framework menampilkan peringatan hydration mismatch, UI berkedip, atau komponen merender ulang dengan isi berbeda. Dalam praktiknya, ini hampir selalu berarti output render server tidak identik dengan render pertama di klien.
Pada aplikasi yang memakai GraphQL, sumber mismatch paling umum bukan hanya dari komponen UI, tetapi dari cache GraphQL, initial state yang tidak identik, auth state yang berubah antara server dan browser, field yang bergantung pada waktu atau API browser, serta urutan fetch yang berbeda. Artikel ini membahas gejala, akar masalah, checklist diagnosis, dan langkah perbaikan yang praktis dengan contoh pola umum menggunakan Apollo Client atau urql tanpa bergantung pada versi tertentu.
Apa itu hydration mismatch pada GraphQL SSR?
Pada SSR, server merender HTML berdasarkan data yang tersedia saat request diproses. Setelah halaman dimuat, aplikasi di browser melakukan hydration: framework mengaitkan event handler dan state ke HTML yang sudah ada. Jika render awal di browser menghasilkan struktur DOM atau isi teks yang berbeda dari HTML server, muncullah mismatch.
Dalam konteks GraphQL SSR, mismatch biasanya terjadi saat:
- Server merender dengan hasil query A, tetapi klien memulai dengan cache kosong lalu menampilkan loading.
- Server merender untuk user yang terautentikasi, tetapi klien belum memiliki token/cookie yang sama saat render awal.
- Server mengembalikan data dengan field yang berubah cepat, misalnya timestamp relatif, lalu klien menghitung ulang nilainya beberapa milidetik kemudian.
- Urutan eksekusi query di server dan klien berbeda, sehingga cache yang terbaca saat render pertama juga berbeda.
Aturan dasarnya sederhana: render pertama di klien harus membaca data dan state yang semirip mungkin dengan yang dipakai server saat menghasilkan HTML.
Gejala umum yang sering muncul
Masalah hydration mismatch tidak selalu muncul sebagai error fatal. Beberapa gejala yang umum:
- Peringatan seperti Text content does not match server-rendered HTML.
- Isi komponen berubah sesaat setelah halaman tampil.
- Elemen tertentu hilang lalu muncul lagi setelah query di browser selesai.
- Status loading terlihat di klien padahal HTML server sudah berisi data final.
- Konten untuk user login berubah menjadi konten guest, atau sebaliknya.
- Daftar item berubah urutan atau jumlahnya setelah hydration.
Kalau gejala ini hanya muncul sesekali di produksi, penyebabnya sering terkait kondisi yang tidak deterministik: cookie tidak terbaca konsisten, cache bocor antar-request di server, waktu render berbeda, atau query dijalankan ulang tanpa memakai hasil SSR.
Akar masalah utama pada aplikasi GraphQL dengan SSR
1. Cache GraphQL server dan klien tidak sinkron
Ini adalah penyebab paling umum. Server sudah menjalankan query GraphQL, merender halaman, lalu mengirim HTML. Namun di browser, client GraphQL dibuat dengan cache kosong atau dengan bentuk state yang tidak sama. Akibatnya, komponen yang tadinya membaca data dari cache SSR sekarang melihat cache kosong dan merender loading atau data berbeda.
Pola yang benar adalah:
- Jalankan query yang dibutuhkan saat SSR.
- Ekstrak cache hasil query di server.
- Serialisasi cache itu ke HTML secara aman.
- Di browser, buat client GraphQL dengan cache hasil SSR tersebut sebelum komponen merender.
Jika salah satu langkah ini tidak dilakukan atau urutannya keliru, hydration mismatch sangat mungkin terjadi.
2. Initial state tidak identik
Selain cache GraphQL, aplikasi sering punya state tambahan: user, locale, feature flag, preferensi tema, filter pencarian, atau hasil parsing URL. Jika server memakai satu nilai dan klien memakai nilai lain saat render pertama, HTML akan berbeda.
Contoh klasik:
- Server menganggap locale =
id, klien mendeteksi locale dari browser =en. - Server membaca cookie tema =
dark, klien default kelightsebelum cookie diproses. - Server membaca query string yang sudah dinormalisasi, klien membaca state router yang belum siap.
Masalah ini sering terlihat seperti masalah UI, padahal akar sebenarnya adalah state bootstrap yang tidak disamakan antara server dan browser.
3. Field bergantung waktu, timezone, atau API browser
Field seperti new Date(), waktu relatif (5 menit lalu), timezone lokal, ukuran viewport, window.location, localStorage, atau status online browser tidak tersedia atau tidak identik di server. Jika nilai itu dipakai langsung saat render SSR, hasilnya mudah berbeda saat klien menghitung ulang.
Contoh yang berisiko:
function PublishedAt({ iso }) {
return <span>{formatRelativeTime(iso, Date.now())}</span>;
}Jika server merender 1 menit lalu dan klien menghitung ulang menjadi 2 menit lalu, konten teks berbeda walau selisihnya kecil.
Solusi umumnya:
- Kirim nilai yang sudah final dari server jika memang harus stabil.
- Atau render placeholder yang stabil saat SSR, lalu perbarui setelah komponen mounted.
- Hindari akses langsung ke API browser dalam render pertama.
4. Auth state berbeda antara server dan klien
Pada SSR, server biasanya membaca cookie atau header untuk menentukan identitas user dan menjalankan query sesuai konteks autentikasi. Di browser, client GraphQL mungkin memakai token dari storage atau cookie yang belum tersedia saat inisialisasi pertama. Akibatnya, server merender versi user login, tetapi klien merender versi guest.
Masalah ini sangat umum jika:
- Token disimpan di
localStoragedan baru dibaca setelah aplikasi berjalan. - Server memakai cookie HttpOnly, tetapi client-side auth bootstrap memakai mekanisme lain.
- Ada refresh token asinkron sebelum query klien dijalankan.
Jika SSR mengandalkan auth, cara paling aman adalah memastikan sumber auth untuk server dan klien konsisten, dan hasil identitas awal user disertakan dalam state bootstrap jika memang dibutuhkan untuk render pertama.
5. Urutan fetch server dan klien tidak sama
Pada aplikasi nyata, satu halaman bisa memicu beberapa query: data user, navigasi, detail halaman, rekomendasi, atau fitur personalisasi. Jika di server query sudah selesai sebelum render, tetapi di klien sebagian query dijalankan ulang dengan urutan berbeda, komponen tertentu bisa membaca cache yang belum siap dan menghasilkan HTML awal yang berbeda.
Masalah ini makin mudah terjadi saat:
- Komponen memicu query secara kondisional.
- Ada dependency antara satu query dan query lain.
- Variabel query diturunkan dari state yang berubah saat hydration.
- Client melakukan refetch otomatis segera setelah mount.
Checklist diagnosis: cari mismatch secara sistematis
Sebelum memperbaiki, pastikan sumber perbedaan benar-benar teridentifikasi. Checklist berikut membantu mempersempit masalah.
Bandingkan input render server vs klien
- Apakah variabel query sama persis?
- Apakah user auth yang dipakai server dan klien sama?
- Apakah locale, timezone, route params, dan feature flags identik?
- Apakah komponen membaca
window,document, atau storage saat render pertama?
Verifikasi cache GraphQL hasil SSR
- Apakah query benar-benar dijalankan di server sebelum render?
- Apakah cache diekstrak dari instance client yang sama dengan yang dipakai saat SSR?
- Apakah hasil ekstraksi dikirim ke HTML?
- Apakah browser menginisialisasi cache dari state itu sebelum pohon komponen dirender?
Periksa apakah ada render loading yang tidak seharusnya
- Jika HTML server sudah berisi data, mengapa klien masih menampilkan loading?
- Apakah query klien dijalankan ulang padahal data SSR sudah tersedia?
- Apakah ada opsi fetch policy yang memaksa network request lebih dulu?
Audit nilai yang tidak deterministik
- Timestamp relatif
- ID acak
- Format tanggal berdasarkan locale browser
- Urutan array yang tidak dijamin
- Konten berdasarkan viewport atau media query
Cek kebocoran state antar-request di server
Pada server SSR, client GraphQL dan cache harus dibuat per request, bukan singleton global. Jika satu instance cache dipakai bersama, user A bisa mewarisi data user B, dan mismatch akan muncul secara acak sekaligus berbahaya dari sisi keamanan.
Langkah perbaikan yang praktis
1. Buat instance client GraphQL per request di server
Untuk SSR, jangan memakai satu cache global di proses server. Selalu buat instance baru untuk setiap request agar header auth, cookie, dan hasil query terisolasi.
// pseudocode umum, bukan bergantung framework tertentu
function createGraphQLClient({ headers, initialState }) {
return new GraphQLClient({
headers,
cache: createCache().restore(initialState || {})
});
}
async function renderRequest(req) {
const client = createGraphQLClient({
headers: req.headers,
initialState: {}
});
// jalankan query yang diperlukan untuk SSR
await runSsrQueries(client, req);
const initialGraphQLState = client.extractCache();
const html = renderApp({ client, req });
return injectStateIntoHtml(html, { initialGraphQLState });
}Mengapa ini penting? Karena SSR bukan hanya soal performa, tetapi juga soal determinisme output. Client per request memastikan data yang dipakai untuk render hanya milik request tersebut.
2. Serialisasi initial state dengan aman
State hasil SSR biasanya disisipkan ke HTML agar bisa dipakai ulang di browser. Jangan memasukkan JSON mentah tanpa perlindungan dasar, karena karakter tertentu bisa memecah tag <script> atau membuka celah XSS jika ada data tak tepercaya.
Pola amannya:
- Pakai serialisasi JSON yang aman untuk disisipkan ke HTML.
- Escaping karakter berbahaya seperti
<. - Jangan menyisipkan token sensitif jika tidak perlu.
- Batasi state yang benar-benar dibutuhkan untuk hydration.
function safeSerialize(obj) {
return JSON.stringify(obj).replace(/</g, '\\u003c');
}
const stateScript = `
<script>
window.__INITIAL_GRAPHQL_STATE__ = ${safeSerialize(initialGraphQLState)};
window.__INITIAL_APP_STATE__ = ${safeSerialize(initialAppState)};
</script>
`;Idealnya, state yang dikirim hanya yang diperlukan untuk render awal. Mengirim terlalu banyak data membuat HTML membesar dan meningkatkan risiko ketidakkonsistenan jika sebagian state sebenarnya tidak relevan untuk halaman tersebut.
3. Rehydrate cache sebelum aplikasi dirender di browser
Di sisi klien, pastikan client GraphQL dibuat dengan cache hasil SSR sebelum komponen yang memakai query dirender. Jika client dibuat tanpa restore state, komponen akan menganggap data belum ada dan menampilkan loading.
const initialGraphQLState = window.__INITIAL_GRAPHQL_STATE__ || {};
const client = createGraphQLClient({
initialState: initialGraphQLState
});
hydrateApp({ client });Baik Apollo Client maupun urql pada dasarnya mendukung pola serupa: server mengisi cache, browser memulihkan cache tersebut, lalu query pertama membaca hasil yang sama seperti saat SSR.
4. Hindari query ganda saat hydration
Kalau data SSR sudah ada, jangan langsung memicu query yang sama tanpa kontrol. Pada banyak kasus, query ganda tidak selalu menyebabkan mismatch, tetapi sering memunculkan loading flash, perubahan urutan render, atau refetch yang mengubah data terlalu cepat.
Pendekatan yang umum:
- Skip query di klien bila data SSR untuk query itu sudah tersedia.
- Atur fetch policy agar render pertama membaca cache hasil SSR.
- Lakukan refetch setelah mount jika benar-benar perlu data terbaru.
function Page(props) {
const hasSsrData = Boolean(props.ssrReady);
const result = usePageQuery({
variables: { slug: props.slug },
pause: hasSsrData // pola umum setara skip/pause
});
const data = hasSsrData ? props.initialData : result.data;
if (!data) return <PageSkeleton />;
return <PageView data={data} />;
}Nama opsi bisa berbeda antar-library, tetapi prinsipnya sama: jangan membuat render awal di browser jatuh ke state loading jika server sudah punya data final untuk HTML tersebut.
5. Stabilkan loading state
Hydration mismatch sering berasal dari logika seperti ini:
if (loading) return <Spinner />;
return <Content data={data} />;Di server, loading mungkin sudah false karena query selesai. Namun di browser, bila cache belum siap atau query dijalankan ulang, loading menjadi true pada render pertama. Hasilnya, server mengirim Content tetapi klien merender Spinner.
Perbaikannya:
- Pastikan cache SSR direstore sebelum render.
- Gunakan data hasil SSR sebagai sumber render awal.
- Jika perlu refetch, lakukan tanpa mengganti seluruh UI menjadi spinner penuh.
Pola yang lebih aman:
const initialData = props.initialData;
const { data, fetching } = useQuery(...);
const resolvedData = data || initialData;
if (!resolvedData) return <PageSkeleton />;
return (
<>
{fetching ? <InlineRefreshingIndicator /> : null}
<Content data={resolvedData} />
</>
);Dengan pola ini, render awal tetap stabil karena HTML utama tidak diganti spinner hanya karena ada refetch ringan.
6. Pisahkan field browser-only dari render SSR
Jika sebuah bagian UI memang bergantung pada browser, render bagian itu secara client-only atau tunda sampai komponen selesai mount. Ini berlaku untuk:
- Data dari
localStorage - Status viewport
- Integrasi API browser
- Nilai yang berubah berdasarkan waktu lokal user
function ClientOnlyValue() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <span>-</span>;
}
return <span>{window.navigator.language}</span>;
}Trade-off-nya jelas: Anda mengorbankan sedikit kelengkapan SSR untuk menjaga konsistensi hydration. Ini layak dipilih bila nilai tersebut tidak penting untuk SEO atau tampilan awal.
7. Samakan auth bootstrap antara server dan klien
Jika halaman membutuhkan data personal, pilih strategi auth yang konsisten:
- Server dan klien sama-sama mengandalkan cookie yang otomatis ikut pada request.
- Jika identitas user dihitung di server, kirim hasil identitas minimum yang diperlukan sebagai initial app state.
- Jangan membuat klien menunggu pembacaan token asinkron lalu merender ulang total.
Jika auth state belum pasti saat render pertama, pertimbangkan dua pilihan:
- Render halaman dalam bentuk netral yang tidak tergantung user.
- Jadikan area personal sebagai client-only agar tidak memicu mismatch.
Untuk komponen seperti avatar user, tombol akun, atau menu khusus role tertentu, strategi client-only sering lebih aman daripada memaksa SSR yang tidak deterministik.
8. Jaga determinisme urutan dan bentuk data
Meski data GraphQL sama secara isi, hasil render bisa berbeda jika urutan array berubah atau ada field opsional yang hanya tersedia pada salah satu sisi. Pastikan:
- Array disortir secara eksplisit bila urutannya penting untuk UI.
- Normalisasi data dilakukan dengan cara yang sama di server dan klien.
- Variabel query selalu lengkap dan bernilai sama.
- Fallback untuk field null/undefined konsisten.
Contoh alur SSR yang aman secara umum
Berikut pola tinggi-level yang bisa diterapkan baik dengan Apollo Client maupun urql secara umum.
- Terima request di server.
- Buat instance GraphQL client baru untuk request tersebut, termasuk header/cookie request.
- Jalankan query yang dibutuhkan halaman.
- Render aplikasi menggunakan client yang sudah berisi cache hasil query.
- Ekstrak cache dan initial app state.
- Serialisasi state secara aman ke HTML.
- Di browser, baca state itu sebelum render aplikasi.
- Buat GraphQL client dengan restore cache SSR.
- Hydrate aplikasi tanpa memicu render awal yang berbeda.
// SERVER
const client = createGraphQLClient({ headers: req.headers });
await preloadPageQueries(client, routeContext);
const initialGraphQLState = client.extractCache();
const initialAppState = {
user: getUserSnapshot(req),
locale: resolveLocale(req),
route: routeContext.params
};
const html = renderApp({ client, initialAppState });
return htmlWithState(html, initialGraphQLState, initialAppState);
// CLIENT
const client = createGraphQLClient({
initialState: window.__INITIAL_GRAPHQL_STATE__
});
const appState = window.__INITIAL_APP_STATE__;
hydrateApp({ client, appState });Inti dari alur ini bukan library tertentu, melainkan kesamaan sumber data antara server dan render pertama di klien.
Kapan sebaiknya memakai client-only rendering?
Tidak semua mismatch perlu diselesaikan dengan SSR penuh. Ada bagian UI yang lebih masuk akal dijalankan hanya di browser.
Pilih client-only rendering jika:
- Data sangat bergantung pada browser dan tidak penting untuk SEO.
- Auth state tidak stabil saat request awal.
- Komponen berat memakai library yang tidak SSR-friendly.
- Perbedaan nilai antar-user harus diputuskan setelah browser siap.
Contohnya: widget rekomendasi personal yang hanya relevan setelah user login terverifikasi di browser, panel eksperimen A/B yang mengandalkan storage lokal, atau tampilan yang bergantung penuh pada viewport aktual.
Trade-off-nya adalah konten tersebut tidak hadir di HTML awal, sehingga pengalaman awal atau SEO untuk bagian itu berkurang. Karena itu, gunakan client-only secara selektif, bukan sebagai pelarian untuk semua mismatch.
Debugging tips di lingkungan produksi
Log state bootstrap yang dipakai server dan klien
Simpan jejak minimal seperti route params, auth presence, locale, dan apakah cache SSR tersedia. Jangan log data sensitif. Tujuannya untuk membandingkan kondisi saat mismatch terjadi.
Bandingkan HTML awal dengan render klien
Jika memungkinkan, ambil snapshot HTML server untuk komponen yang bermasalah lalu bandingkan dengan output render pertama di browser. Fokus pada teks, urutan list, dan cabang if yang tergantung state.
Deteksi refetch yang terlalu dini
Periksa apakah ada query yang menembak request jaringan segera setelah mount, padahal data sudah tersedia di cache SSR. Ini sering terlihat di network panel atau log link/exchange GraphQL.
Uji skenario auth dan anonymous secara terpisah
Masalah hydration sering hanya muncul untuk user login atau hanya untuk guest. Uji keduanya dengan cookie yang realistis, bukan hanya mode development lokal.
Waspadai perilaku yang lolos di development
Development mode kadang menampilkan gejala lebih jelas, tetapi tidak semua kondisi produksi mudah direplikasi. Latensi jaringan, race condition, dan perilaku cookie pada domain/subdomain tertentu bisa memengaruhi hasil.
Praktik terbaik untuk mencegah mismatch di produksi
- Buat GraphQL client dan cache per request di server, jangan singleton global.
- Preload query SSR yang diperlukan sebelum render HTML.
- Ekstrak dan restore cache SSR secara konsisten antara server dan klien.
- Serialisasi initial state dengan aman dan kirim hanya data yang diperlukan.
- Samakan auth state, locale, route params, dan feature flags antara server dan browser.
- Hindari render awal yang bergantung pada waktu, random, atau API browser.
- Jangan tampilkan loading state berbeda di klien jika server sudah punya data final.
- Skip/pause query di klien bila data SSR sudah tersedia dan render awal harus stabil.
- Refetch setelah mount secara hati-hati tanpa mengganti seluruh UI menjadi spinner.
- Gunakan client-only rendering secara selektif untuk bagian yang memang tidak deterministik di SSR.
- Sortir dan normalisasi data secara eksplisit agar urutan render konsisten.
- Tambahkan logging diagnosis untuk membandingkan state bootstrap server vs klien.
Pada akhirnya, masalah hydration mismatch pada GraphQL SSR bukan sekadar warning tampilan. Ia menandakan bahwa kontrak antara render server dan render pertama di klien belum konsisten. Jika cache GraphQL, initial state, auth, dan urutan fetch dirancang deterministik sejak awal, mismatch bisa dicegah tanpa mengorbankan manfaat SSR.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!