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.

JavaScript type survival across the JSON serialization boundary A table-style SVG comparing JavaScript types against their JSON.stringify fate: primitives survive, special types need tagged encoding, and circular/class structures must be restructured. JavaScript Type JSON.stringify result Required action string, number, boolean, null, array, plain object Preserved exactly None Date ISO-8601 string (loses prototype) Tag + reviver โ†’ new Date() Map Empty object {} Tag entries array + reviver Set Empty object {} Tag values array + reviver BigInt TypeError โ€” throws at stringify Tag string + reviver โ†’ BigInt() Circular reference TypeError โ€” circular structure ID-reference graph or flatted Class instance (methods, prototype) Plain object, methods stripped Pass DTO, reconstruct client-side Survives Partial loss Silent drop or throw

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.


โ† Back to Cross-Boundary Prop Passing