Frontend Development 26 min read

Serializing Non‑Serializable Data in Server‑Side Rendering: Remix and Turbo‑Stream Approaches

This article examines how server‑side rendering can serialize normally non‑serializable data such as Promises, Dates, RegExp, and BigInt, comparing Remix’s defer‑based approach with Turbo‑Stream’s encode/decode method, and discusses their mechanisms, benefits, and challenges.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Serializing Non‑Serializable Data in Server‑Side Rendering: Remix and Turbo‑Stream Approaches

Introduction

In server‑side rendering (SSR) the server fetches data, serializes it, and injects it into the HTML template so the client can access the initial data in the global scope.

Most of this data is transferred as strings, but developers sometimes need to serialize stateful objects such as Promises to enable progressive page loading with Suspense.

This article explores how to serialize data that cannot be directly stringified in SSR.

Promises are just one representative of non‑serializable data; others include RegExp, Date, Symbol, BigInt, etc.

Current Situation

Developers know that fetching data on the server often reduces latency compared to client‑side requests because network delay, server performance, and internal network links are usually more favorable.

After the server finishes its requests, the data must be passed to the client, typically by embedding a <script> that assigns the data to a global variable (e.g., window.__nextData ).

For example, Next.js uses this technique to transfer data.

The blue line in the diagram represents the server‑side request and the subsequent data transfer to the client.

This transfer medium is usually a <script> block that mounts the data onto window , allowing client scripts to read it.

Challenges

Traditional SSR frameworks serialize data with JSON.stringify , which works for basic types but has two major drawbacks.

Redundant data transfer : identical values are duplicated in the JSON payload because stringify does not deduplicate.

Inability to serialize special types : objects such as Promise, Date, RegExp, Symbol, BigInt lose their original structure and methods when stringified.

Example of redundant JSON:

{
  "user1": "A",
  "user2": "A",
  "user3": "A"
}

When many identical values need to be sent, the extra bytes can hurt performance.

Special types become problematic: a Promise becomes an empty object, a Date becomes an ISO string, etc.

With the rise of Web Streaming, many frameworks now render the first paint without waiting for all data, using Suspense to progressively load parts of the UI. This requires the server to serialize a Promise and preserve its state so the client can resume rendering once the Promise resolves.

Streaming is not the focus of this article; see my previous post “Detailed React Streaming Process” for more information.

Solutions

Having identified the problems, we now explore two approaches to serialize non‑serializable data in SSR.

Solution 1 – Remix Serialization Strategy

Remix solves the problem by creating a virtual Promise on the client and pushing the resolved data via a <script /> tag generated during server rendering.

Red indicates server actions; green indicates client execution.

Remix does not serialize the Promise as a string; instead it uses four APIs/components: defer , <Scripts /> , <Await /> , and <Suspense /> .

Usage

In a Remix route, the loader returns a deferred object containing the Promise. The client then uses useLoaderData together with <Await /> to render the resolved value while preserving the original Promise state.

import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { defer } from "@remix-run/node"; // or cloudflare/deno
import { Await, useLoaderData } from "@remix-run/react";
import React, { Suspense } from "react";

const fetchSomeData = () => {
  return new Promise((resolve) => {
    resolve({
      name: "19Qingfeng",
    });
  });
};

export async function loader({ params }: LoaderFunctionArgs) {
  // 👇 fetch user name on the server
  let namePromise = fetchSomeData();

  return defer({
    namePromise,
  });
}

export default function Product() {
  let { namePromise } = useLoaderData();
  return (
Loading...
}>
resolve={namePromise}>
        {(data) => {
          return (
User Name: {data.name}
);
        }}
);
}

The loader runs only on the server, wrapping the Promise with defer . The client receives the same Promise via useLoaderData and <Await /> , allowing the UI to suspend until the Promise resolves.

Mechanism

Remix’s loader is compiled at build time; when a request hits a route, the corresponding loader is invoked on the server.

The defer function creates a DeferredData instance. It iterates over the object entries, calling trackPromise for each Promise, marking it with a _tracked flag.

When a Promise settles, onSettle records either _data (resolved value) or _error (rejection).

The <Scripts /> component injects a series of runtime scripts into the HTML. It extracts the active deferred data from the server context and generates a DeferredHydrationScript for each pending Promise.

On the client, the generated script creates a placeholder Promise in __remixContext.t . When the server later resolves the original Promise, serializeData emits a script that calls __remixContext.r to fulfill the client‑side Promise.

function DeferredHydrationScript({
  dataKey,
  deferredData,
  routeId,
  scriptProps,
  serializeData,
  serializeError,
}: { dataKey?: string; deferredData?: DeferredData; routeId?: string; scriptProps?: ScriptProps; serializeData: (routeId: string, key: string, data: unknown) => string; serializeError: (routeId: string, key: string, error: unknown) => string; }) {
  return (
)}>
      {typeof document === "undefined" && deferredData && dataKey && routeId ? (
}>
          {(data) => (
)}
) : (
)}
);
}

Thus Remix preserves the original Promise state across the server‑client boundary without relying on raw stringification.

Scripts Component

The <Scripts /> component gathers all generated deferredScripts and injects them into the HTML. Each script either registers a pending Promise ( __remixContext.n ) or resolves it ( __remixContext.r ).

The generated __remixContext.n creates a new Promise on the client and stores its resolve/reject callbacks in __remixContext.t . When the server later calls __remixContext.r , the stored resolve is invoked with the actual data.

__remixContext.n = function(i, k) {
  __remixContext.t = __remixContext.t || {};
  __remixContext.t[i] = __remixContext.t[i] || {};
  let p = new Promise((r, e) => {
    __remixContext.t[i][k] = { r: (v) => { r(v); }, e: (v) => { e(v); } };
  });
  setTimeout(() => {
    if (typeof p._error !== "undefined" || typeof p._data !== "undefined") return;
    __remixContext.t[i][k].e(new Error("Server timeout."));
  }, 5000);
  return p;
};

When the server finally resolves the Promise, __remixContext.r runs:

__remixContext.r = function(i, k, v, e) {
  let p = __remixContext.t[i][k];
  if (typeof e !== "undefined") {
    let x = new Error(e.message);
    x.stack = e.stack;
    p.e(x);
  } else {
    p.r(v);
  }
};

This mechanism enables true bidirectional Promise state transfer.

Solution 2 – Turbo‑Stream Strategy

Turbo‑Stream takes a more conventional approach: it defines an encode function that custom‑serializes data (including Promise, RegExp, Date, Map, Set, etc.) on the server, streams the encoded payload, and then decodes it on the client with decode .

The custom serializer preserves type information, avoiding the loss incurred by plain JSON.stringify . It also maintains a stringified array to deduplicate identical values, reducing redundant network traffic.

The above diagram shows Turbo‑Stream’s internal stringify logic.

On the server, encode processes the data, assigns type tags, and enqueues the result into a Web Stream. The client continuously reads the stream, calls decode , and reconstructs the original objects with their proper types.

The above diagram shows the client‑side decode process.

Because the serializer tracks already‑stringified values, if two Promises resolve to the same value, the second one references the existing entry instead of sending duplicate data.

Remix’s upcoming future.unstable_singleFetch flag enables Turbo‑Stream under the hood, allowing developers to opt‑in to this more efficient serialization method.

Conclusion

As Web Streaming becomes more prevalent, the data exchange between server and client grows increasingly complex. The two serialization strategies presented—Remix’s defer‑based approach and Turbo‑Stream’s custom encode/decode—provide viable solutions for transmitting otherwise non‑serializable data while minimizing redundancy and preserving type fidelity.

Hope the explanations help you implement reliable data transfer in your SSR applications. Thank you for reading.

reactSSRRemixData SerializationWeb StreamingTurbo-Stream
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.