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.
Frequently Asked Questions
Why does JSON.stringify silently drop Map and Set values?
JSON.stringify only serializes plain objects, arrays, strings, numbers, booleans, and null. Map and Set have no JSON representation, so they are coerced to empty objects {}. You must convert them to arrays of entries or values before serialization, then reconstruct on the client.
How do I detect a hydration mismatch caused by a serialization bug?
Set NODE_ENV=development and enable framework hydration warnings (React hydrateRoot, Astro --verbose). Capture server HTML with document.documentElement.outerHTML before hydration and diff it against the post-hydration DOM. Mismatches in data-* attributes or text content indicate a reviver gap.
What is the safe inline JSON size limit per island?
The practical limit is 15 KB of inline JSON per island. Beyond that, synchronous JSON.parse on a mid-tier mobile CPU takes more than 15 ms, blocking the main thread past the performance budget. For larger payloads, defer parsing to requestIdleCallback or a Web Worker.
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