Passing Complex Objects from Server to Client Islands
When a streaming SSR response embeds island state as inline JSON, Map, Set, Date, BigInt, and circular object graphs silently corrupt or vanish. Engineers using cross-boundary prop passing patterns first notice the failure as a hydration mismatch or a runtime TypeError on the client โ not at the serialization site on the server. This guide gives you a reproducible workflow to audit your object graph, fix the serialization contract, and verify the result with measurable performance targets.
The serialization wall: what crosses and what does not
The server-client boundary in islands architecture is enforced by JSON.stringify. The table below shows which JavaScript types survive unmodified, which require a tagged encoding, and which must be restructured entirely before they can cross.
Prerequisites
Step 1 โ Audit the object graph before serialization
Goal: identify every non-JSON-safe value before it reaches JSON.stringify, so failures are caught at the server and produce actionable errors rather than silent data loss.
// server/auditProps.ts
// Run this at the server boundary before calling JSON.stringify on island props.
function auditSerializability(
obj: unknown,
path = 'root',
seen = new WeakSet()
): string[] {
const issues: string[] = [];
if (obj === null || typeof obj !== 'object' && typeof obj !== 'function') {
// primitives are safe
if (typeof obj === 'bigint') issues.push(`${path}: BigInt โ will throw`);
if (typeof obj === 'symbol') issues.push(`${path}: Symbol โ silently dropped`);
if (typeof obj === 'function') issues.push(`${path}: function โ silently dropped`);
return issues;
}
if (seen.has(obj as object)) {
issues.push(`${path}: circular reference โ will throw`);
return issues;
}
seen.add(obj as object);
if (obj instanceof Date) issues.push(`${path}: Date โ prototype lost; use tagged encoding`);
if (obj instanceof Map) issues.push(`${path}: Map โ becomes {} without replacer`);
if (obj instanceof Set) issues.push(`${path}: Set โ becomes {} without replacer`);
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
issues.push(...auditSerializability(v, `${path}.${k}`, seen));
}
return issues;
}
// Usage: throw early on the server rather than silently dropping data
const issues = auditSerializability(islandProps);
if (issues.length > 0) throw new Error(`Island props contain non-serializable values:\n${issues.join('\n')}`);
Expected output: a thrown Error with a precise path like root.filters: Map โ becomes {} without replacer, pointing directly to the field you need to fix.
Step 2 โ Implement a tagged replacer/reviver pair
Goal: encode every non-primitive type as a plain object with a __type discriminant, then reconstruct it faithfully on the client.
// shared/serialization.ts
// Import this module on both server (Node/Deno/Bun) and client.
// The __type discriminant is the contract โ never change these string values.
export const serializeIslandProps = (obj: unknown): string =>
JSON.stringify(obj, (_key, value) => {
// Date: normalize to UTC ISO-8601 to eliminate timezone drift
if (value instanceof Date)
return { __type: 'Date', iso: value.toISOString() };
// Map: preserve key/value pairs as an entries array
if (value instanceof Map)
return { __type: 'Map', entries: Array.from(value.entries()) };
// Set: preserve members as a values array
if (value instanceof Set)
return { __type: 'Set', values: Array.from(value) };
// BigInt: stringify to avoid the TypeError thrown by default
if (typeof value === 'bigint')
return { __type: 'BigInt', str: value.toString() };
return value;
});
export const deserializeIslandProps = <T>(json: string): T =>
JSON.parse(json, (_key, value) => {
if (value && typeof value === 'object' && '__type' in value) {
switch (value.__type) {
case 'Date': return new Date(value.iso as string);
case 'Map': return new Map(value.entries as [unknown, unknown][]);
case 'Set': return new Set(value.values as unknown[]);
case 'BigInt': return BigInt(value.str as string);
}
}
return value;
}) as T;
Verify the round-trip in a Node REPL before embedding in the renderer:
node -e "
const { serializeIslandProps, deserializeIslandProps } = require('./shared/serialization');
const original = { d: new Date('2024-01-01T00:00:00Z'), m: new Map([['a', 1]]), s: new Set([42n]) };
const roundTrip = deserializeIslandProps(serializeIslandProps(original));
console.assert(roundTrip.d instanceof Date, 'Date reconstructed');
console.assert(roundTrip.m instanceof Map, 'Map reconstructed');
console.assert(roundTrip.s instanceof Set, 'Set reconstructed');
console.log('All assertions passed. Payload size:', serializeIslandProps(original).length, 'bytes');
"
Step 3 โ Embed and validate the payload in the server renderer
Goal: inject the serialized payload into the correct island hydration script tag and validate that it is complete before the streaming flush emits that chunk.
// server/renderIsland.ts (React 18 / Next.js App Router example)
import { renderToReadableStream } from 'react-dom/server';
import { serializeIslandProps } from '../shared/serialization';
export async function renderIslandWithProps<T extends object>(
component: React.ComponentType<T>,
props: T,
islandId: string
): Promise<ReadableStream<Uint8Array>> {
// Serialize before streaming so any throws surface synchronously on the server
const serialized = serializeIslandProps(props);
// Sanity check: verify the payload parses back without throwing
JSON.parse(serialized); // will throw if truncated or malformed
const hydrationScript = `<script>
window.__ISLAND_STATE__ = window.__ISLAND_STATE__ || {};
// ${islandId}: props injected at SSR time, deserialized by the island on mount
window.__ISLAND_STATE__["${islandId}"] = ${serialized};
<\/script>`;
// Combine the serialized state with the component's HTML stream
const componentStream = await renderToReadableStream(
React.createElement(component, props),
{ bootstrapScriptContent: hydrationScript }
);
return componentStream;
}
Check the emitted chunk in DevTools: open Network โ find the streaming HTML document โ search for __ISLAND_STATE__. The JSON object should terminate before the closing </script> tag with no truncation.
Step 4 โ Defer large payloads off the main thread
Goal: keep synchronous JSON.parse under 15 ms on a mid-tier mobile CPU by deferring payloads above 15 KB to requestIdleCallback or a Web Worker. This directly protects FCP in streaming SSR scenarios where the render arrives before the user interacts.
// client/hydrateIsland.ts ('use client' directive in Next.js / Astro client:load)
export function hydrateIslandState<T>(
islandId: string,
onReady: (state: T) => void
): void {
const raw = window.__ISLAND_STATE__?.[islandId];
if (!raw) return;
// Inline strings under ~2 KB are parse-cheap; deserialize synchronously
if (typeof raw !== 'string' || raw.length < 2048) {
onReady(raw as T);
return;
}
// Payloads over 2 KB: measure first, defer if needed
const start = performance.now();
const sample = raw.slice(0, 512);
JSON.parse(sample); // warm the parser for timing
const projectedMs = (performance.now() - start) * (raw.length / 512);
if (projectedMs < 8) {
// Fast enough to parse synchronously
const { deserializeIslandProps } = await import('../shared/serialization');
onReady(deserializeIslandProps<T>(raw));
return;
}
// Slow payload: defer to idle time
const schedule = 'requestIdleCallback' in window
? (cb: () => void) => requestIdleCallback(cb, { timeout: 2000 })
: (cb: () => void) => setTimeout(cb, 0);
schedule(async () => {
const { deserializeIslandProps } = await import('../shared/serialization');
const state = deserializeIslandProps<T>(raw);
window.dispatchEvent(
new CustomEvent(`island:ready:${islandId}`, { detail: state })
);
onReady(state);
});
}
Step 5 โ Handle circular references with an ID-reference graph
Goal: when the object graph contains cycles (parent nodes referencing children that reference the parent, common in tree structures and doubly-linked lists), replace JSON.stringify with a reference-safe encoder.
// server/circularSafe.ts
// Encodes an object graph with cycles into a flat ID-reference map.
interface RefGraph {
root: string; // ID of the root object
nodes: Record<string, unknown>;
}
export function encodeWithRefs(obj: unknown): RefGraph {
const nodes: Record<string, unknown> = {};
const seen = new Map<object, string>();
let counter = 0;
function encode(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value;
if (seen.has(value as object)) {
// Return a reference token instead of re-serializing
return { __ref: seen.get(value as object) };
}
const id = `n${counter++}`;
seen.set(value as object, id);
const encoded: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
encoded[k] = encode(v);
}
nodes[id] = encoded;
return { __ref: id };
}
const rootRef = encode(obj) as { __ref: string };
return { root: rootRef.__ref, nodes };
}
// Client-side: reconstruct the graph from the reference map
export function decodeWithRefs<T>(graph: RefGraph): T {
const resolved = new Map<string, unknown>();
function resolve(nodeId: string): unknown {
if (resolved.has(nodeId)) return resolved.get(nodeId);
const node = graph.nodes[nodeId] as Record<string, unknown>;
const obj: Record<string, unknown> = {};
resolved.set(nodeId, obj); // register before resolving children to handle cycles
for (const [k, v] of Object.entries(node)) {
obj[k] = (v && typeof v === 'object' && '__ref' in v)
? resolve((v as { __ref: string }).__ref)
: v;
}
return obj;
}
return resolve(graph.root) as T;
}
Verification
Confirm all five steps are working by checking these signals together:
1. Zero-mismatch CI assertion. Add a test that serializes your real island props, round-trips them through serializeIslandProps / deserializeIslandProps, and deep-equals the result. Run this in CI against 100+ prop fixtures with varied inputs.
2. DevTools Network check. Load the page, open Network, click the document request, and go to Response. Search for __ISLAND_STATE__. Verify the embedded JSON is valid by pasting it into the Console: JSON.parse(document.querySelector('script[data-island]').textContent) should not throw.
3. Performance mark budget. Instrument the client hydration path:
performance.mark('island-deserialize-start');
const state = deserializeIslandProps(raw);
performance.mark('island-deserialize-end');
performance.measure('island-deserialize', 'island-deserialize-start', 'island-deserialize-end');
const [entry] = performance.getEntriesByName('island-deserialize');
console.assert(entry.duration < 15, `JSON.parse took ${entry.duration}ms โ exceeds budget`);
4. Heap snapshot. In DevTools Memory, take a snapshot after hydration. Filter by Map and Set. Confirm instances appear with the correct entry counts โ not as empty objects โ proving the reviver ran successfully.
| Metric | Acceptable | Target |
|---|---|---|
| Inline JSON per island | under 20 KB | under 15 KB |
JSON.parse duration (mobile) |
under 20 ms | under 15 ms |
| Hydration mismatch rate | under 0.1 % | 0 % |
| Main-thread blocking (serialization) | under 80 ms | under 50 ms |
| Time to island interactive | under 600 ms | under 400 ms |
Troubleshooting
TypeError: Converting circular structure to JSON
Root cause: your object graph contains a reference cycle โ a child node points back to a parent that is already being serialized. JSON.stringify has no built-in cycle handling.
Fix: replace JSON.stringify with encodeWithRefs from Step 5, or install the flatted package (flatted.stringify / flatted.parse) which handles cycles transparently. Check your data model for doubly-linked lists, parent-pointer trees, or objects that store a reference to the root store.
Hydration mismatch: server HTML and client VDOM differ on date/time fields
Root cause: new Date().toString() depends on the serverโs timezone. If the server renders a formatted date string into HTML and the client reconstructs a Date object in a different timezone, the two representations diverge.
Fix: always normalize Date values to UTC ISO-8601 on the server before embedding them in the serialized payload. Use value.toISOString() in the replacer (as in Step 2). Never embed locale-formatted date strings in island props โ format on the client after deserialization using the userโs local Intl.DateTimeFormat.
Island state is undefined at mount โ window.__ISLAND_STATE__ key missing
Root cause: the hydration script flushed in a different streaming chunk than the island component HTML, so the islandโs useEffect or onMount ran before the script executed.
Fix: ensure the <script> tag embedding the payload is rendered immediately before the islandโs root element, inside the same React Suspense boundary or Astro island wrapper โ not in the document <head>. This guarantees the script executes before the islandโs hydration entry point fires. In Next.js App Router, use <script dangerouslySetInnerHTML> co-located with the Server Component that renders the islandโs container.
Related
- Cross-Boundary Prop Passing โ the serialization contract patterns this pageโs techniques build on.
- Event Delegation in Partially Hydrated Apps โ coordinate between islands after complex state arrives on the client.
- Implementing Suspense Boundaries in Next.js 14 โ align streaming flush points so serialized payloads reach the client before island activation.
โ Back to Cross-Boundary Prop Passing