Promises across the void: Streaming data with RSC

Last week we looked at how React Server Component Payloads work under the covers. Towards the end of that article I mentioned a fascinating thing that you can do with RSC: sending unresolved promises from the server to the client. When I first read that I thought it was a documentation bug, but it's actually quite real (though with some limitations).

Here's a simple example of sending a promise from the server to the client. First, here's our server-rendered component, called SuspensePage in this case:

page.tsx
import { Suspense } from "react";
import Table from "./table";
import { getData } from "./data";

export default function SuspensePage() {
return (
<div>
<h1>Server Component</h1>
<Suspense fallback={<div>Loading...</div>}>
<Table dataPromise={getData(1000)} />
</Suspense>
</div>
);
}
page.tsx
import { Suspense } from "react";
import Table from "./table";
import { getData } from "./data";

export default function SuspensePage() {
return (
<div>
<h1>Server Component</h1>
<Suspense fallback={<div>Loading...</div>}>
<Table dataPromise={getData(1000)} />
</Suspense>
</div>
);
}

So we just imported a getData() function that returns a promise that resolves after 1 second. This simulates a call to a database or other asynchronous action. Here's our fake getData() function:

data.tsx
const fakeData = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
]

export async function getData(delay: number): Promise<any> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(fakeData)
}, delay)
})
}
data.tsx
const fakeData = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
]

export async function getData(delay: number): Promise<any> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(fakeData)
}, delay)
})
}

We pass that promise to a component called Table as a prop called dataPromise. Here's that component (note the use client directive, which tells the compiler that this component will be run on the client):

table.tsx
"use client";
import { use } from "react";

export default function Table({ dataPromise }: { dataPromise: Promise<any> }) {
const data = use(dataPromise)

return (
<table className="max-w-5xl table-auto text-left">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.map((row: any) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.name}</td>
</tr>
))}
</tbody>
</table>
)
}
table.tsx
"use client";
import { use } from "react";

export default function Table({ dataPromise }: { dataPromise: Promise<any> }) {
const data = use(dataPromise)

return (
<table className="max-w-5xl table-auto text-left">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.map((row: any) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.name}</td>
</tr>
))}
</tbody>
</table>
)
}

dataPromise is not a good name for a prop in reality, but I call it that here to make it clear that this is a Promise, not the data itself. We don't get the actual data until that Promise resolves.

Note that although React Server Components can be async functions, we're not actually writing our server-rendered SuspensePage component using async, nor are we making the client-side Table function an async one (that's partially because async components are not yet supported on the client side, but also partly because we don't need to).

Async/await-ish via the power of use()

This component uses the new React use hook to wait for the promise to resolve. use accepts a Promise as an argument, and does some clever things:

  • If the promise is already resolved, it returns the resolved value.
  • If the promise is not resolved, it suspends the component and waits for the promise to resolve.
  • If the promise rejects, it throws an error.

Under the covers, in that second scenario (promise not yet resolved), use will actually throw the Promise, which is caught by React and used to suspend the component. This is how React knows to wait for the promise to resolve before rendering the component. That thrown Promise will be caught by the nearest Suspense boundary, which will then show the fallback until the Promise resolves.

When the Promise does eventually resolve, React will re-render the component with the resolved value. If we used use() multiple times, the pattern will repeat until all of the Promises have resolved and all of the components rendered (or until some of the Promises reject and the nearest Error Boundary renders).

How can I start a Promise on the server and have it resolve on the client?

The key to sending a Promise from the server to the client is to not await it on the server. If you await the Promise on the server, you'll be sending the resolved value to the client, not the Promise itself, but the Promise may take some time to resolve, during which UI rendering is blocked and your user left waiting.

As for how this actually works under the covers, take a look at my post on how React Server Component Payloads work, but the high level flow goes like this:

  1. The server renders the component tree, and notices that an unresolved Promise is being passed as a prop to a client-side component.
  2. The server assigns an internal ID to that Promise, and sends that ID to the client in place of the Promise itself.
  3. The client renders the component tree, and notices that a Promise ID is being passed as a prop to a client-side component. This suspends render until:
  4. When the Promise resolves on the server-side, the server renders an inline <script> tag with the resolved value, keyed on the Promise ID it generated.

The key to all this working is that the server is streaming the HTML response to the client, and doesn't actually close that stream until it has finished rendering everything, including resolved Promises. So in our case above, the server would likely render our very basic SuspensePage component in a few milliseconds, but then keep the stream open for another second while it waits for the getData() Promise to resolve.

At that point, thanks to the streamable nature of HTML, the server can just send a bit more response HTML in the form of that <script> tag, which will trigger a next.js (in this case) callback that will update the client-side component with the resolved value.

See it in action

I created a simple, live and hosted example of this on my RSC Examples site. You can see this example at https://rsc-examples.edspencer.net/promises/resolved. To view the example directly, skipping the explanation, take a look at https://rsc-examples.edspencer.net/examples/promises/resolved. If you run this curl command you can see the server response streaming in real time:

curl -D - --raw https://rsc-examples.edspencer.net/examples/promises/resolved
curl -D - --raw https://rsc-examples.edspencer.net/examples/promises/resolved

What you'll see there is the server sending the majority of the HTML response, pause for 1 second, then spit out a final <script> tag that looks like this, along with a <div> tag that we'll get to in a moment:

<script>self.__next_f.push([1,"9:[{\"id\":1,\"name\":\"Alice\"},{\"id\":2,\"name\":\"Bob\"},{\"id\":3,\"name\":\"Charlie\"}]\n"])</script>
<script>self.__next_f.push([1,"9:[{\"id\":1,\"name\":\"Alice\"},{\"id\":2,\"name\":\"Bob\"},{\"id\":3,\"name\":\"Charlie\"}]\n"])</script>

self.__next_f.push is a next.js function that overrides the push method on the __next_f array, which under the covers fires a bunch of logic to handle whatever the server is sending. I cover that process in a lot more detail in this article about how React Server Component Payloads work, but at a high level: the ID that React generated for our dataPromise Promise was ID=9, and so when this <script> tag is executed, under the covers next.js will figure out that the resolved Promise with ID=9 needs to go back into the dataPromise prop of the Table component.

Now that the promise has resolved on the server, been streamed across to the client and then re-constituted as a Promise again, the client component re-renders, this time with a resolved dataPromise and is therefore able to fully render. We really ended up with 2 Promises - one on the server, the other a reconstituted version of that Promise on the client, but in our code we can treat them as the same thing.

Now let's take a look at that <div> tag that was also sent by the server (it also has a second <script> tag tacked on there). I've formatted this slightly to make it more readable as it usually comes down in a single line:

<div hidden id="S:0">
<table class="max-w-5xl table-auto text-left">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Alice</td>
</tr>
<tr>
<td>2</td>
<td>Bob</td>
</tr>
<tr>
<td>3</td>
<td>Charlie</td>
</tr>
</tbody>
</table>
</div>
<script>
$RC=function(b,c,e){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var d=a.data;if("/$"===d)if(0===f)break;else f--;else"$"!==d&&"$?"!==d&&"$!"!==d||f++}d=a.nextSibling;e.removeChild(a);a=d}while(a);for(;c.firstChild;)e.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};
$RC("B:0","S:0")
</script>
<div hidden id="S:0">
<table class="max-w-5xl table-auto text-left">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Alice</td>
</tr>
<tr>
<td>2</td>
<td>Bob</td>
</tr>
<tr>
<td>3</td>
<td>Charlie</td>
</tr>
</tbody>
</table>
</div>
<script>
$RC=function(b,c,e){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var d=a.data;if("/$"===d)if(0===f)break;else f--;else"$"!==d&&"$?"!==d&&"$!"!==d||f++}d=a.nextSibling;e.removeChild(a);a=d}while(a);for(;c.firstChild;)e.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};
$RC("B:0","S:0")
</script>

So at the same time as the server sent the <script> tag with the resolved Promise, it also sent this <div> tag with the fully rendered table. This is a neat trick that next.js does to make the page load faster: it sends the server-rendered HTML along with the Promise, so that the client can instantly render the page with the server-rendered HTML, and then hydrate it with the resolved Promise when it arrives.

That second <script> tag just defines a function that replaces an existing element on the page with id B:0 with the element with id S:0. The S:0 element is the server-rendered table that just streamed down, and the B:0 element is a Suspense-rendered placeholder that allows React to drop this delayed content into the right place in the DOM. When the <Table> initially attempted to render on the server, but was suspended due to the unresolved Promise, it rendered a placeholder instead of the actual table, with an ID of B:0.

Limitations and gotchas

But you can't just send any Promise from the server to the client. The value that the Promise ultimately resolves has to be either a simple native data type like a string, number or float, or a plain JS object/array, or a rendered React component. If the Promise resolves to anything else, you'll get an error on the client side when React tries to render it.

I put together a second example at https://rsc-examples.edspencer.net/promises/various-datatypes that shows the ability to load a variety of different data types. Here's a video of that example in action:

This is a video. Click it to open the live example page

Here we see strings, numbers, floats, plain objects and arrays being sent across the void, as well as a React component, which is a cool thing to be able to do. The React component is a simple one that just renders a string, but it could be anything you like. The full example is at https://rsc-examples.edspencer.net/promises/various-datatypes.

There is a separate example at https://rsc-examples.edspencer.net/promises/rendering-components that focuses on just a React component being rendered without all of the other types so you can see how that works in isolation.

Share Post:

What to Read Next