Anatomi Masalah: Streaming SSR dan Out-of-Order Chunk Delivery
Streaming Server-Side Rendering (SSR) memanfaatkan mekanisme Transfer-Encoding: chunked pada HTTP/1.1 atau stream multiplexing pada HTTP/2 dan HTTP/3 untuk mengirimkan fragmen HTML segera setelah selesai dirender di backend. Komponen asynchronous (seperti komponen di dalam React Suspense boundary) tidak lagi memblokir rendering keseluruhan halaman; backend mengirimkan fallback markup terlebih dahulu, lalu menyusulkan markup final saat data asynchronous selesai di-resolve.
Masalah muncul ketika beberapa asynchronous boundaries selesai secara acak (out-of-order) dan dikirim ke browser. Browser mengeksekusi streaming HTML secara progresif seraya mengunduh bundel JavaScript di thread utama. Jika runtime client framework memulai proses hidrasi parsial sebelum browser mengeksekusi script DOM swap bawaan streaming server, terjadi hydration mismatch fatal yang berujung pada kerusakan tree UI atau CSR bailout total.
Akar Masalah Teknis Desinkronisasi DOM
1. Desinkronisasi Suspense Boundary dan DOM Markers
Server streaming menandai boundary asynchronous dengan marker khusus di dalam HTML, umumnya berupa comment node seperti <!--$?--> dan <!--/$-->. Ketika chunk susulan tiba di akhir stream, backend menyertakan markup hasil render di dalam elemen <template> tersembunyi, diikuti script eksekusi instan untuk menukar node fallback dengan konten final di DOM tree:
<!-- Fallback awal di dokumen -->
<div id="boundary-1">
<!--$?--><div class="skeleton-loader"></div><!--/$-->
</div>
<!-- Chunk susulan yang di-stream belakangan -->
<template id="B:1">
<article class="resolved-content"><h3>Data Loaded</h3></article>
</template>
<script>
$RC('B:1', 'boundary-1');
</script>Jika framework client menjalankan hidrasi saat JavaScript diaktifkan sebelum tag <script> penukar dieksekusi, hidrator membaca comment marker fallback tetapi VDOM internal client mengharapkan resolved node. Kondisi ini menghasilkan error klasifikasi DOM reparenting:
Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.
Expected server HTML to contain a matching <article> in <div id="boundary-1">.2. Data Serialization Race Condition
Komponen client membutuhkan state deserialisasi (misal data query cache) agar dapat mencocokkan virtual node dengan server DOM. Apabila state dikirimkan pada footer payload setelah stream selesai, sedangkan chunk HTML parsial sudah memicu inisialisasi hidrasi, komponen membaca cache bernilai undefined. Hasilnya, client memicu branch fallback sedangkan DOM fisik sudah berganti menjadi konten resolved.
Solusi 1: Colocated Inline State Script per Suspense Boundary
Hindari penempatan global state buffer di akhir dokumen HTML (footer stream). Tempatkan payload data atomik tepat di dalam chunk yang sama dengan elemen <template> sebelum fungsi reparenting dijalankan. Ini menjamin data tersedia di microtask queue sebelum runtime hidrasi mengevaluasi node.
// server-boundary-renderer.ts
import { serializeJavascript } from './utils';
export function renderResolvedChunk(boundaryId: string, markup: string, data: unknown): string {
// ponytail: inline script parsing carries ~0.2ms overhead; fine until >500 boundaries.
const serializedData = serializeJavascript(data, { isJSON: true });
return `
<template id="payload:${boundaryId}">${markup}</template>
<script>
window.__STREAM_DATA__ = window.__STREAM_DATA__ || {};
window.__STREAM_DATA__['${boundaryId}'] = ${serializedData};
$RC('${boundaryId}');
</script>
`;
}Solusi 2: Deterministic Micro-Chunk Buffering
Ketika server asynchronous worker (seperti model concurrent stream pada arsitektur web modern) menghasilkan beberapa chunk secara bersamaan, HTTP response stream sering memecah blok script dan template ke dalam TCP packet terpisah. Buat sebuah deterministic transform stream di layer server untuk memvalidasi kelengkapan token DOM marker sebelum chunk di-flush ke socket klien.
// stream-buffer-transform.ts
import { Transform, TransformCallback } from 'node:stream';
export class BoundaryBufferTransform extends Transform {
private buffer: string = '';
_transform(chunk: Buffer | string, encoding: BufferEncoding, callback: TransformCallback): void {
this.buffer += chunk.toString('utf-8');
// Cek apakah buffer memiliki pasangan template dan script swap yang utuh
const boundaryCompleteRegex = /<template id="[^"]+">[\s\S]*?<\/template>\s*<script>[\s\S]*?<\/script>/g;
let match: RegExpExecArray | null;
let lastIndex = 0;
while ((match = boundaryCompleteRegex.exec(this.buffer)) !== null) {
const completeBlock = match[0];
this.push(completeBlock);
lastIndex = match.index + completeBlock.length;
}
// Simpan sisa fragmentasi yang belum utuh untuk stream flush berikutnya
this.buffer = this.buffer.slice(lastIndex);
callback();
}
_flush(callback: TransformCallback): void {
if (this.buffer.length > 0) {
this.push(this.buffer);
}
callback();
}
}Pipeline ini memastikan browser parser menerima pasangan node data dan script eksekusi dalam satu frame eksekusi parser, mencegah window di mana template sudah masuk DOM namun script penukar belum sempat dievaluasi.
Solusi 3: Graceful Fallback saat Stream Terputus
Jika koneksi jaringan klien terputus sebelum semua out-of-order chunk terkirim, klien berisiko terjebak pada fallback shell yang tidak dapat dihidrasi. Buat hydration coordinator yang mendeteksi status stream dan secara eksplisit melakukan downgrade ke client-side data fetching.
// client-hydration-coordinator.ts
export function initHydrationCoordinator() {
const pendingBoundaries = new Set(document.querySelectorAll('[data-hydration-pending]'));
window.addEventListener('DOMContentLoaded', () => {
// Verifikasi apakah stream backend selesai normal
if (!window.__STREAM_COMPLETED__) {
console.warn('SSR stream interrupted. Falling back to CSR for unresolved boundaries.');
pendingBoundaries.forEach((boundary) => {
const boundaryId = boundary.getAttribute('data-boundary-id');
if (!window.__STREAM_DATA__?.[boundaryId]) {
// Reset DOM marker untuk trigger client mount manual
boundary.removeAttribute('data-hydration-pending');
window.dispatchEvent(new CustomEvent(`trigger-csr:${boundaryId}`));
}
});
}
});
}Panduan Pengambilan Keputusan
- Pilih Deterministic Buffering jika arsitektur backend Anda menggunakan proxy HTTP/1.1 yang agresif memecah paket MTU atau edge gateway tanpa HTTP/2 streaming buffer.
- Pilih Colocated State jika aplikasi menggunakan dynamic suspense tree bertingkat di mana data antarkomponen saling independen.
- Hindari teknik hidrasi selektif berbasis event-replay tanpa sinkronisasi state per boundary, karena dapat memicu race condition saat user berinteraksi dengan elemen interaktif sebelum chunk final dieksekusi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!