Progressive JSON: Sending Data Breadth-First Instead of Depth-First

Anyone who has waited for a slow webpage knows the pain of a large JSON payload. The client receives bytes, but it cannot JSON.parse until the final byte arrives. A slow server-side operation—say, a database query for comments—blocks the entire response, so the client sits idle until everything is ready.

Streaming JSON parsers offer a partial fix by parsing incomplete input into a partial object tree. But this approach has a fundamental limitation: the objects are malformed until the stream ends. A top-level object missing its footer, or an array of comments that might still grow, makes it hard for application logic to consume the data safely. Stream order follows document order, so the slowest part delays everything after it.

A Breadth-First Approach

Progressive JSON takes a different route: send data breadth-first instead of depth-first. The server emits an outer shell first, using placeholders like "$1", "$2" for pieces not yet sent. Later rows fill in those placeholders out of order; there is no requirement that $2 arrives before $3. On the client, unresolved placeholders become Promises that resolve as their data streams in.

Consider an initial payload for a page:

{
  header: "$1",
  post: "$2",
  footer: "$3"
}

If the server is still computing the post but has the header and footer ready, it can send those rows next, leaving $2 pending. Later, the post row can arrive with its own nested placeholders for comments:

{
  header: "$1",
  post: "$2",
  footer: "$3"
}
/* $1 */
"Welcome to my blog"
/* $3 */
"Hope you like it"
/* $2 */
{
  content: "$4",
  comments: "$5"
}
/* $4 */
"This is my article"

On the client, the Promise for post resolves, while a new Promise appears for its comments. When those finally stream in, the whole tree is complete. This out-of-order delivery means one slow query no longer holds up unrelated content that is ready to render.

Batching and Inlining

Sending every scalar value as a separate stream row is wasteful. The format is flexible enough to let the server choose a batching strategy. If only two operations are slow—loading the post and loading its comments—the server can emit a compact sequence. First, the outer shell with three placeholders. Then one row for the full post object with a nested placeholder for comments. Then a final row containing all comments. The client reconstructs the tree in stages that match the server's actual bottlenecks, rather than forcing a row per field.

This approach also enables a natural form of outlining. When serializing plain JSON, repeated objects—for instance, { name: 'Dan' } appearing in two places—are duplicated in the output. With rows, the first occurrence can be emitted standalone, and later references become placeholders pointing back to that row. This deduplicates the stream and even allows cyclic structures, since a cycle is simply an object property that references its own row.

React Server Components

This design is essentially how React Server Components (RSC) transport works. The RSC payload is a progressive JSON stream representing a component tree. As the server resolves data, it emits rows that fill nodes in the client's tree. A page authored this way initially has a Promise where the post should be, then the post arrives, then the comments.

However, RSC does not force the UI to jump around as data lands. This is the key architectural difference: React does not reveal "holes" for pending promises unconditionally. Instead, pending data is gated behind declared <Suspense> boundaries. Without any <Suspense> boundaries, the client receives data progressively but waits to reveal the whole page at once. Wrapping part of the tree in <Suspense> opts int a staged reveal—say, showing header and footer first with a glimmer for comments, then popping in comments when they resolve.

The result is that the data delivery order and the UI reveal order are decoupled. Data streams as fast as it is available, but the visual loading sequence follows the developer's design. The Promises in the tree act like a throw, and <Suspense> acts like a catch to present intentional loading states.

A General Mechanism

While RSC is the most visible implementation, progressive JSON is a general idea. Turning that tree into HTML for SSR is one application, but the format applies to SPA-style navigation as well. Any system that cannot start client work until all server work finishes—or where a single slow dependency stalls everything behind it—is a candidate. The programming model also matters: streaming alone is insufficient. The consumer must handle incomplete information gracefully, and declarative loading boundaries are one way to do that.