The renderer parks it

The function suspends on ; the renderer parks it, resumes when the row returns, and folds the data into the returned elements.

One sentence, four verbs, two of them belonging to JavaScript and two to React. This page takes an async Server Component apart along that seam: what the engine does at , what the Flight server does with the promise it is handed, what reaches the wire and when, and what a boundary changes for whoever consumes the result. Every timing and every row in the traces below was captured from the real renderer, not sketched.

JavaScript levelReact task levelFlight wireSuspense / consumerdatameasurements
evidence: React 19.3.0, react-server-dom-webpack 19.3.0, Node v22.22.2, production buildscompanion to Two Renderers, One Requestclick any or diagram box

Two levels, one promise

The sentence reads as if React pauses a function and later un-pauses it. It does not. The async function suspends itself — that is engine behaviour, defined by the language — and React tracks a task that depends on the promise the function handed back. The two levels touch at exactly one object: the component’s own promise.

['', '', '', '', '', '', '', '', '']Diagram: the JavaScript level and the React task level, joined by the component’s promiseJAVASCRIPT LEVELREACT LEVELasync functionArticlePage() is calledawaitsuspends the execution contextPromisethe same object the React task depends oncontinuationruns as a microtask when it settlesrendering taskrenderModel walks the elementdepends on PromisereadThenable throws that promisemark / track as pendingnew task, "$L" placeholderprocess other worksiblings, other pinged tasksPromise resolves → pingretry / resume rendering

The async function suspends itself. React tracks the unfinished task that depends on it. Everything else on this page is a consequence of keeping those two sentences apart.

The six steps of the sentence

The component under the microscope, with its four significant lines annotated. The figures that follow are bound to these lines: line 1 is invoked, line 2 suspends and resumes, lines 4–7 fold the row into elements.

app/articles/[id]/page.tsx
1async function ArticlePage() {
2 const article = await db.query("SELECT * FROM articles WHERE id = 42");
3 return (
4 <article>
5 <h1>{article.title}</h1>
6 <p>{article.body}</p>
7 </article>
8 );
9}
L1 — asyncthe call returns a promise; React treats the promise as a task dependency
L2 — awaitthe execution context suspends here; the thread does not
L3 — continuationruns as a microtask after the row is back, with bound
L5 — fold is read while this element is constructed

React invokes the Server Component

The renderer effectively does . Because the function is , the call does not produce the element tree; it produces a promise, while the body has already started executing.

['', '', '', '', '', '', '', '', '']Diagram: calling an async Server Component yields a pending promise, not elementsArticlePage()Promisecomputation is still runningcalling it does not produce the element treepending — returned to the renderer

It reaches await

sends the query and returns a promise that the database has not fulfilled yet. The function cannot continue to its because does not exist yet.

['', '', '', '', '', '', '', '', '']Diagram: db.query returns a pending promise while the database worksdb.query()Promisedatabasetakes timethe promise is not fulfilled yet, so the function cannot reach its return

What “suspends” means

The execution context of the async function is suspended: it is removed from the , its local bindings stay alive in its environment record, and a reaction on the awaited promise holds the only reference back to it. Control returns to the caller. It does not mean the Node.js thread is frozen — the server continues with other work.

['', '', '', '', '', '', '', '', '']Diagram: the execution context suspends at await, waits, then resumes before the returnArticlePage executionconst article = await db.query(...)SUSPENDwait for Promiseresume herereturn <article>...</article>the execution context of theasync function is suspendedthe Node.js thread is not frozenthe server keeps doing other work

Three requests in flight on one thread: A is parked at its while B and C are rendered. When the row returns, A resumes. “Suspends” describes the async computation, never the thread.

['', '', '', '', '', '', '', '', '']Diagram: three requests on one thread — request A parked at its await while B and C runRequest AArticlePageawait DBRequest Banother componentrunsRequest Canother requestrunsDB resolvesArticlePage resumes"suspends" means the async computation pauses, not the server threadone thread, three requests in flight: A is parked at its await while B and C run

What “the renderer parks it” means

React’s Flight server meets a component whose result is not available yet. It cannot finish that subtree, so it keeps track of the unfinished work as a task — an entry with a status, a dependency and a row id — and continues with whatever other rendering work it can do. The task is not thrown away; it is waiting for its promise to become readable.

['', '', '', '', '', '', '', '', '']Diagram: the renderer’s work list with ArticlePage paused and the rest finishedRENDERER WORKHeaderNavigationArticlePageFooterArticle comments← waiting for DBArticlePagePromise for its eventual resultpendingReact cannot finish that subtree yet.It keeps track of the unfinished workand continues with whatever otherrendering it can do — that is "parks it".

The database promise resolves

Fulfilment queues the continuation. When it runs, evaluates to the row object, is bound, and execution resumes at the next line.

['', '', '', '', '', '', '', '', '']Diagram: the query promise fulfils, the row becomes an ordinary binding and execution resumesdb.query(...)Promise fulfilled{  id: 42,  title: "Understanding Streams",  body: "..."}const article = await db.query(...)becomes an ordinary binding of the rowexecution resumes HEREthe line after the await runs next

Now the element tree is constructed

This is “folds the data into the returned elements”: the JSX expressions read and while the are built. The data becomes part of the tree the component returns.

['', '', '', '', '', '', '', '', '']Diagram: a JSX expression reads article.title while the element tree is built<h1>{article.title}</h1>article.title"Understanding Streams"evaluated while the element is built<article>  <h1>Understanding Streams</h1>  <p>...</p></article>the data has become part of theelement tree the component returns

The invariant. The element tree is not created with missing data and filled in later. The component’s returned tree is constructed after the awaited data is available — one construction, with the values in it.

The entire pipeline

The whole run, from the request to the first Flight row, as one chain. Step through it; each stage lights up in the colour of the level it belongs to.

1/15
['', '', '', '', '', '', '', '', '']Stepped diagram: the whole run from the server request to the first Flight rowSERVERArticlePage()async function startsdb.query(...)PromisependingSUSPENDrenderer can doother workDB returns rowPromise fulfillsArticlePage resumesarticle =returned rowJSX expressionsevaluateElement treeReact Server rendererFlight payloadthe source pipeline, top to bottom, read as a snake: left to right, then right to left

Inside the renderer, line by line

The same run again, but with the Flight server’s own functions in the frame. The instrument is bound to two panes: the component above, and the condensed server source below. As you step, the lines that are executing take the colour of their level.

1/10
['', '', '', '', '', '', '', '', '']Stepped instrument: the JavaScript level, the Flight server functions, the wire and the database across one suspensionJAVASCRIPT LEVEL — THE ASYNC FUNCTIONREACT TASK LEVEL — FLIGHT SERVERWIRE — ROWS WRITTEN TO THE RESPONSEDATABASEPage()ArticlePagebody runsawaitsuspendssuspendedcontextcontinuationmicrotaskreturnelement treeone call, one promise, one continuation — nothing here runs twicerenderModelwalks the elementrenderFunctionComponentcalls ArticlePage(props)lazy wrapperpayload: the promisereadThenablethrows the pending promisenew task, id 1returns "$L1" to the parentsiblings continueFooter renders, row 0 flushespingsetImmediate(performWork)retryTaskreads the fulfilled lazyemitChunkrow 1 serialised0:[[header],"$L1",[footer]]1:["$","article",null,{...}]query sentrow returns, promise fulfilled
ReactFlightServer.js — condensedreact-server-dom-webpack 19.3.0
1// react-server-dom-webpack@19.3.0 — condensed from the server build
2function renderElement(request, task, type, key, ref, props) {
3 if (typeof type === 'function') {
4 return renderFunctionComponent(request, task, key, type, props);
5 }
6 // symbols (Suspense, Fragment), lazy, forwardRef, memo … (elided)
7}
8
9function renderFunctionComponent(request, task, key, Component, props) {
10 const result = Component(props); // ArticlePage(props)
11 const model =
12 processServerComponentReturnValue(request, task, Component, result);
13 return renderModelDestructive(request, task, emptyRoot, '', model);
14}
15
16function processServerComponentReturnValue(request, task, Component, result) {
17 if (typeof result.then === 'function') { // an async component
18 return createLazyWrapperAroundWakeable(request, task, result);
19 }
20 return result;
21}
22
23function createLazyWrapperAroundWakeable(request, task, wakeable) {
24 if (typeof wakeable.status !== 'string') {
25 wakeable.status = 'pending';
26 wakeable.then(
27 v => {
28 if (wakeable.status === 'pending') {
29 wakeable.status = 'fulfilled'; wakeable.value = v;
30 }
31 },
32 e => {
33 if (wakeable.status === 'pending') {
34 wakeable.status = 'rejected'; wakeable.reason = e;
35 }
36 },
37 );
38 }
39 return { $$typeof: REACT_LAZY_TYPE, _payload: wakeable, _init: readThenable };
40}
41
42function readThenable(thenable) {
43 if (thenable.status === 'fulfilled') return thenable.value;
44 if (thenable.status === 'rejected') throw thenable.reason;
45 throw thenable; // pending: throw the promise
46}
47
48function renderModel(request, task, parent, key, value) {
49 try {
50 return renderModelDestructive(request, task, parent, key, value);
51 } catch (thrownValue) {
52 const m = task.model, wasReactNode = m != null && typeof m === 'object' &&
53 (m.$$typeof === REACT_ELEMENT_TYPE || m.$$typeof === REACT_LAZY_TYPE);
54 const x = thrownValue === SuspenseException
55 ? getSuspendedThenable() : thrownValue;
56 if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
57 const newTask = createTask(request, task.model, task.keyPath, ...);
58 const ping = newTask.ping;
59 x.then(ping, ping);
60 return wasReactNode ? serializeLazyID(newTask.id) // "$L1"
61 : serializeByValueID(newTask.id); // "$1"
62 }
63 // an error: log it, emit an E row, reference that row (elided)
64 }
65}
66
67function serializeLazyID(id) { return '$L' + id.toString(16); }
68
69function pingTask(request, task) {
70 request.pingedTasks.push(task);
71 if (request.pingedTasks.length === 1) {
72 setImmediate(() => performWork(request)); // Node build, after OPENING
73 }
74}
75
76function performWork(request) {
77 const pinged = request.pingedTasks; request.pingedTasks = [];
78 for (const task of pinged) retryTask(request, task);
79 flushCompletedChunks(request); // rows leave the process
80}
81
82function retryTask(request, task) {
83 try {
84 const model =
85 renderModelDestructive(request, task, emptyRoot, '', task.model);
86 emitChunk(request, task, model); // id.toString(16) + ':' + json
87 task.status = COMPLETED;
88 request.abortableTasks.delete(task);
89 } catch (thrownValue) {
90 const x = thrownValue === SuspenseException
91 ? getSuspendedThenable() : thrownValue;
92 if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
93 task.status = PENDING; // the root task waits in place
94 x.then(task.ping, task.ping);
95 } else erroredTask(request, task, x);
96 }
97}
L10 — invokethe synchronous part of the body runs inside this call
L17 — detectan async component is recognised only by what it returned
L45 — suspendthe promise unwinds the stack up to the nearest catch
L59 — subscribe is registered after the status tracker, so it runs second
L60 — placeholderwhat the parent row receives at this position
L72 — latera fresh check-phase turn; never inside the promise reaction
L79 — flushcompleted rows are written after every pass
L93 — root casewith no parent row to hold a placeholder, the task waits in place

Four things this makes precise:

The crucial distinction

The wrong picture is a single frozen line: await, React pauses everything, the database returns, React continues. The right picture is a function that returns control while its promise is pending, and a continuation that runs when the promise fulfils — with React as the consumer of the result.

['', '', '', '', '', '', '', '', '']Diagram: the wrong mental model of a frozen renderer beside the right oneDON'T THINKTHINKawaitReact pauses everythingdatabase returnsReact continuesa single frozen thread — wrongasync functionexecutesreaches awaitreturns control while itsPromise is pendingcontinuation resumes afterPromise fulfillmentelement tree created

Client Component versus Server Component

The familiar client pattern renders twice — once without data, once with — and keeps the data in state between the two. The server pattern renders once, after the data, and keeps the data in a local binding of a suspended function.

['', '', '', '', '', '', '', '', '']Diagram: a Client Component’s two renders beside a Server Component’s single renderCLIENT COMPONENTSERVER COMPONENTrenderloading UIuseEffect()fetch()setState(data)render again with datainvoke componentawait DBresumeconstruct element tree WITH dataserialize / render resulttwo renders: one without data, one withone construction, after the data
Client Componentasync Server Component
renderstwice: loading UI, then with dataonce, after
where the data waitscomponent statea local binding in a suspended execution context
who triggers the second pass after nobody — the continuation finishes the only pass
what the consumer receivesa tree that changes latera tree built with the values in it

How the renderer continues

React does not literally pause one JavaScript call and jump into another component’s call stack. On the server the renderer maintains a set of tasks; when one meets an unresolved promise, that task is suspended and the walk that contained it carries on. Start with the tree.

['', '', '', '', '', '', '', '', '']Diagram: the component tree beside the React elements Page returnedPageHeaderArticlePageFooterasyncFragment<Header />"Please render Header"<ArticlePage />"Please render ArticlePage"<Footer />"Please render Footer"left: what Page renders; right: the React elements Page() returned — descriptions, not DOM

returns three React elements — descriptions, not DOM. The root task serialises that array with , invoking each component as the walk reaches it. When it reaches , the promise comes back pending and the task below is created.

['', '', '', '', '', '', '', '', '']Diagram: the work queue entry created for the suspended ArticlePage taskRender ArticlePagedb.query(...)Promise pendingWORK QUEUEArticlePagestatus: waitingdependency: DB Promiseid: nextChunkId++ → 1model: the lazy wrapperparked = tracked, not discarded

Now the part the sentence compresses into “continues with whatever other rendering work it can do”. Because the walk is a JSON walk, continuing needs no scheduler: returned a string, so visits the next array element. runs in the same synchronous pass, 0.1 ms after the suspension. The walk is sequential — Header, then ArticlePage, then Footer — but it is not blocking, because returned immediately.

1/5
['', '', '', '', '', '', '', '', '']Stepped diagram: the root walk visiting Header, ArticlePage and FooterROOTHeaderArticleFooterfinishedwaitingfinishedthe walk is sequential, not blocking

Not every component gets a row. A common picture has Header finishing “and producing a record”, then Footer. In production builds synchronous siblings are serialised inline into the parent’s row; only tasks produce rows, and tasks come from the root and from suspensions. Development builds outline every Server Component so debug information ( rows) can attach to it, which is why a dev trace shows far more rows than a production one.

What the client sees meanwhile — where Suspense comes in

While is parked there may be no browser-visible result at all. React does not invent a placeholder for a component that has none. A boundary is the only source of a , and it changes nothing at the two levels above — only what a consumer may show before the row arrives.

['', '', '', '', '', '', '', '', '']Diagram: the tree with a Suspense boundary, and what is ready while the article waitsPageHeaderSuspensefallback: ArticleSkeletoncontent: ArticlePageFooterwhile ArticlePage is waitingPageHeaderSuspenseFooterArticleSkeletonthe parts that are ready can be shownwhile the suspended content is pending

The boundary is an ordinary element to the Flight server: its type is , serialised as in its own row and referenced from the boundary element, whose is the placeholder . The decision is made by whoever renders that element.

['', '', '', '', '', '', '', '', '']Diagram: a boundary chooses the fallback or the content depending on readinessCONTENT READY?NOYESfallbackcontent

What a boundary changes, level by level

levelwithout a boundarywith a boundary
JavaScriptidentical: one call, one suspension, one continuationidentical
React taskstask 1, placeholder task 2, placeholder ; the symbol takes id 1
Flight rowsrow 0 at 0.6 ms, row 1 at 41.2 msrows 1 and 0 at 0.7 ms, row 2 at 41.2 ms
Fizz (HTML)shell cannot complete; the whole document goes out in one write after row 1shell at with the fallback; + later
Fiber (navigation)transition stays pending; old page staysfallback shows in the new boundary
errornothing isolates it: the shell errors (); on a navigation it reaches the nearest marks the boundary client-rendered; catches it there

The HTML side, from the capture

Fizz consumes the same rows that go to the browser. With a boundary it emits the shell as soon as row 0 is decoded, then patches the content in when row 2 arrives.

['', '', '', '', '', '', '', '', '']Diagram: the first HTML chunk with the boundary markers and the later chunk that swaps the content infirst HTML chunk — the shell, sent at onShellReady<header>My site</header><!--$?--><template id="B:0"><div class="skeleton">Loading…</div><!--/$--><footer>© 2026</footer>later chunk — when Flight row 2 arrives on the SSR side<div hidden id="S:0"><article>…</article></div><script>$RC("B:0","S:0")</script>$RC finds template B:0, moves S:0's children into the boundary and flips the "$?" commentto "$" — the fallback is replaced in place, before hydration.A rejected row produces $RX("B:0", digest) instead: the boundary is marked "$!" (client-rendered)and the fallback stays until the client retries.

Why “$L” and not “$”

The reason a boundary can catch the hole at all is the reference kind Flight wrote into the parent row. A lazy reference lets the parent resolve now and suspends only where it is rendered; a plain reference would block the parent row itself.

['', '', '', '', '', '', '', '', '']Diagram: a lazy reference compared with a plain reference"$L2" — LAZY REFERENCE"$2" — PLAIN REFERENCErow 0 resolves immediatelyposition becomes a lazy elementrendering it suspends → boundaryrow 0 is blocked on row 2nothing in row 0 is usable yetused for plain data, not for children

In the App Router the boundary usually comes from : Next.js wraps the segment’s page in when that file exists. Without it, and without your own , an async behaves like the “with siblings” scenario: the renderer continues, the response waits.

What is unfinished, and when the tree is finalized

Before the database resolves, has not reached its , so there is no returned tree at all — not a tree with a blank title. After it resolves the function resumes and the result exists.

['', '', '', '', '', '', '', '', '']Diagram: no returned tree before the row arrives, a complete tree after itbefore the database resolvesArticlePage()await[no return yet]cannot construct <article><h1>????</h1>the function has not reached its returnafter it resolvesarticle<article><h1>"Understanding Streams"

“Finalized” can mean three different things, and they happen at three different moments.

['', '', '', '', '', '', '', '', '']Diagram: three different meanings of “finalized” — component result, render tree, Flight responseA — COMPONENT RESULTB — COMPLETE RENDER TREEC — THE FLIGHT RESPONSEawait DBresumereturn JSXArticlePage's elementresult existsfinalized when the asyncfunction returnsPageHeaderArticlePageFooterReact finishes each pieceof work as its dependencyresolves — the whole pageis not final at one instantReact element /component treeServer rendererFlight rowsnetwork responsestreamed progressively;closes when every taskhas completed
finalized…whenevidence
A · the component’s returned treethe async function returnscontinuation at 40.9 ms, promise fulfilled in the same microtask turn
B · the complete render treeevery task has completed empty →
C · the Flight responsethe stream closes — rows were written as tasks completedrow 0 at 0.6 ms, row 1 at 41.2 ms, closed at 41.2 ms

The response is therefore not “build the entire tree, freeze it, serialise it, send it”. It is a loop.

['', '', '', '', '', '', '', '', '']Diagram: the render, produce, send loop that replaces one final serialisationrender ready workproduceFlight outputsendavailable outputmoreasync work resolvesrendernewly available workproducemore Flight outputsend more outputnot: build the entire tree, freeze it, serialize everything, send

Work units, precisely

The best mental model is not “one giant element tree with holes that fill”. It is a set of tasks, each owning one row id, each completing when its own dependency resolves.

['', '', '', '', '', '', '', '', '']Diagram: root work with two complete branches and one waiting on a database promiseROOT WORKHeaderArticleFootercompletewaitingcompleteDB Promiseresolvesresume workArticle elementscomplete

A task moves through a small state machine. The parked async component moves through it once: created , retried, completed. The root task suspends in place instead of spawning a child, and a component using can bounce several times if it reads several promises.

['', '', '', '', '', '', '', '', '']Diagram: the life of a Flight task from pending to completedPENDINGcreated, pinged onceRENDERINGretryTask runs the modelPENDING againx.then(ping, ping)COMPLETEDrow emitted, task removeda task can bounce between RENDERING and PENDING many times; an async component bounces once

The order of events in one turn

The row returns in a poll-phase callback; everything queued by that callback runs before the loop moves on. The retry is deliberately pushed to the check phase.

['', '', '', '', '', '', '', '', '']Diagram: poll phase, microtasks and check phase in the turn that writes the rowPOLL PHASE — SOCKET READMICROTASKS AFTER THAT CALLBACKCHECK PHASE — SETIMMEDIATEdriver resolves the promisedb.query promise fulfilledcontinuation runsreturn → componentpromise fulfilledstatus trackerpingperformWork → retryTaskrow 1 serialisedflushCompletedChunks

Flight is not “the element tree serialised once at the end”

The picture to discard first.

['', '', '', '', '', '', '', '', '']Diagram: the wrong model — render everything, freeze, serialise once, sendServer Component treerender everythingwait for all awaitscomplete element treeserialize entire treeFlight responseone giant final element tree,serialised once at the end— the wrong model.the probes show the opposite:row 0 leaves the server 40 msbefore the article row exists

The better model: rows are produced as tasks complete, and the stream is the sum of those rows. Synchronous siblings do not produce rows of their own — they are serialised inside the row of the task that rendered them.

['', '', '', '', '', '', '', '', '']Diagram: rows produced as each piece of server work completesServer renderingHeader finishesrendered inline into row 0Article hits await DBsuspended → task 1, "$L1"Footer finishesrendered inline into row 0row 0 → stream → clientDB resolvesArticle resumesrow 1 → stream → clientrows are produced as tasks complete; synchronous siblings ride inside their parent's row

The server renderer is doing two things at once: executing components (with their synchronous work, awaits, suspensions and resumptions) and producing rows for whatever has completed.

['', '', '', '', '', '', '', '', '']Diagram: the renderer executing components and producing Flight rows at the same timeReact rendererexecute componentsproduce Flight rows- synchronous work- await- suspend- resumethe wire representation of theserver-rendering result, emittedrow by row as work completes

With the boundary in place, the structure goes out first — Header, Footer, the boundary with its fallback and its placeholder — and the article follows as its own row when its task completes.

['', '', '', '', '', '', '', '', '']Diagram: the Flight stream for a page with a boundary, earlier rows then later rowsPageHeaderreadyArticlewaiting for DBFooterreadyFLIGHT STREAMrow 0: "Header is here"row 0: "Footer is here"row 0: Suspense boundary, fallback,pending content = "$L2"──── earlier rows ────DB resolvesArticle() → <ArticleView article={...} />row 2: Article's elements──── new rows ────────►the renderer establishes the structurefirst: everything ready, plus theboundary with its pending hole

Rows, not a tree

Flight is not . It is a protocol of typed rows and references; the client keeps a table of chunks by id and reconstructs elements as rows arrive.

['', '', '', '', '', '', '', '', '']Diagram: server work encoded to Flight rows and decoded back on the clientServerrender workReact modelencodeFLIGHT ROWS0: root model1: "$Sreact.suspense"2: article elementsE: error digestI: client referencenetworkClient Flight decoderReact model /client treenot JSON.stringify(entireReactElementTree) — a protocol with typed rows and references
['', '', '', '', '', '', '', '', '']Diagram: the anatomy of one Flight row and the two kinds of reference1:["$","article",null,{"children":[...]}]\nrow id (hex), one line per row; the id is the task id that produced it["$", type, key, props] — the element encoding: "$" marks an element; type is a tag name,a "$S…" symbol or a "$L…"/"$…" reference to a client component; props hold the children"$L1"lazy reference → a React.lazy at that position; the parent resolves now"$1"plain reference → the parent waits for row 1 before it resolves
seen in the probesmeaningexample
element marker at the head of an element array[“$”,”article”,null,{…}]
lazy reference to another row — a suspendable hole“$L1”, “$L2”, “$L3”
plain reference to another row — blocks the containing row“$1” → the Suspense symbol
registry symbol“$Sreact.suspense”
error row — digest only in production2:E{“digest”:”digest-reject”}
debug info (dev builds), plus for awaited async work5:D{“awaited”:”$a”,…}

Three different things

A React element, the server task that renders it, and the wire rows that encode the result are not the same object at any point; the client’s decoded tree is a fourth.

['', '', '', '', '', '', '', '', '']Diagram: React elements, server rendering work and Flight wire rows as three distinct things1 — React elements<Article title="Hello" />2 — Server rendering workthe task that renders it3 — Flight wire rows1:["$","article",...]interpreted by rendererencoded by Flightwire rowsFlight decoderReact's client-side representationthree different things — not the same object at any point
['', '', '', '', '', '', '', '', '']Diagram: the invariant — suspend, resume, element result, Flight encoding, streamArticlePage()await DBsuspendedother rendering continuesDB resolvesresumeArticle element resultFlight encodingFlight row(s)stream to clientthe thing being streamed is not"the element tree" — it is the protocolrepresentation of finished work,emitted as each task completes

The key invariant. Flight is a streaming protocol for communicating the result of Server Component rendering. The thing being streamed is not the element tree; it is the protocol representation of finished work, emitted row by row as the renderer’s tasks complete.

Scenarios

Ten shapes of the same mechanism, each executed through the real Flight server (React 19.3.0, production build) with a fake driver whose latencies are fixed at 40 ms for the article and 15 ms for the comments. Four of them were also rendered to HTML by Fizz in a separate SSR process, the way Next.js does it.

The async component is the whole page

components — alone at the root
1export default async function ArticlePage() {
2 const article = await db.query("SELECT * FROM articles WHERE id = 42");
3 return (
4 <article>
5 <h1>{article.title}</h1>
6 <p>{article.body}</p>
7 </article>
8 );
9}
L1 — root taskthe element is the root task's model — there is no parent row
L2 — awaitsuspends the root task in place

With nothing beside it there is no parent row to hold a placeholder. The root task catches the thrown promise itself (‘s catch), stays PENDING, and is retried when the row returns. One row, written at 40.6 ms.

Every mechanism of the sentence still ran: the body started at 0.4 ms, suspended, resumed at 40.1 ms, and the row was written after the the continuation itself queued.

Executed trace — Flight process, production build, timings from after a warm-up render. Step through the events; rows light up as they are written.

1/12
0 msReactrenderToPipeableStream(<Page/>) called
0.1 msReactrenderToPipeableStream returned — no component has run yet
0.1 msReactpipe(destination) called
0.4 msJSPage() runs synchronously
0.4 msJSArticlePage() body starts
0.4 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) called → Promise pending
40.1 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) settled after 40 ms
40.1 msJSArticlePage() resumes after await; article.title = “Understanding Streams”
40.1 msJSmicrotask queued right after resume runs
40.2 msJSsetImmediate queued right after resume runs
40.6 mswirerow written: 0:[“$”,”article”,null,{“children”:[[“$”,”h1”,null,{“children”:”U…
40.6 msstreamFlight stream closed (every task completed)
40.6 ms 0:["$","article",null,{"children":[["$","h1",null,{"children":"Understanding Streams"}],["$","p",null,{"children":"A stream is a queue of Buffers…"}]]}]

Header, ArticlePage, Footer — no boundary

components — with siblings
1function Page() {
2 return (
3 <>
4 <Header />
5 <ArticlePage />
6 <Footer />
7 </>
8 );
9}
10function Header() { return <header>My site</header>; }
11function Footer() { return <footer>© 2026</footer>; }
L5 — suspendsbecomes task 1; "$L1" takes its place
L6 — continuesruns at 0.5 ms, long before the row is back
L10 — inlineno task, no row of its own

This is “the renderer parks it” made visible. ran 0.1 ms after suspended, and row 0 — with Header, the placeholder and Footer — was written at 0.6 ms. Row 1 followed at 41.2 ms.

The HTML side tells the other half of the story: with no boundary, Fizz cannot complete the shell until row 1 has arrived, so the browser receives one write containing everything. The renderer continued; the response did not.

Executed trace — Flight process, production build, timings from after a warm-up render. Step through the events; rows light up as they are written.

1/15
0 msReactrenderToPipeableStream(<Page/>) called
0.1 msReactrenderToPipeableStream returned — no component has run yet
0.1 msReactpipe(destination) called
0.4 msJSPage() runs synchronously
0.4 msJSHeader() runs synchronously
0.4 msJSArticlePage() body starts
0.4 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) called → Promise pending
0.5 msJSFooter() runs synchronously
0.6 mswirerow written: 0:[[“$”,”header”,null,{“children”:”My site”}],”$L1”,[“$”,”footer…
40.8 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) settled after 40 ms
40.9 msJSArticlePage() resumes after await; article.title = “Understanding Streams”
40.9 msJSmicrotask queued right after resume runs
40.9 msJSsetImmediate queued right after resume runs
41.2 mswirerow written: 1:[“$”,”article”,null,{“children”:[[“$”,”h1”,null,{“children”:”U…
41.2 msstreamFlight stream closed (every task completed)
0.6 ms 0:[["$","header",null,{"children":"My site"}],"$L1",["$","footer",null,{"children":"© 2026"}]] 41.2 ms 1:["$","article",null,{"children":[["$","h1",null,{"children":"Understanding Streams"}],["$","p",null,{"children":"A stream is a queue of Buffers…"}]]}]

SSR process — the same scenario rendered to HTML by Fizz while the Flight rows stream in from a separate process. This clock starts when the child process is spawned, so the first rows land about 80 ms in; read the order and the gaps, not the absolute numbers.

1/10
5 msFlightspawned Flight process (react-server condition)
5.3 msSSRcreateFromNodeStream(flightStream) → thenable model
5.4 msFizzrenderToPipeableStream(<Root/>) called
6.1 msFizzrenderToPipeableStream returned
81.7 msFlightFlight row(s) arrived: 0:[[“$”,”header”,null,{“children”:”My si…
120.6 msFlightFlight row(s) arrived: 1:[“$”,”article”,null,{“children”:[[“$”,…
121.7 msFizzonShellReady — everything outside Suspense boundaries is rendered; pipe(res) now
122.8 msHTMLHTML chunk written (202 bytes): <!DOCTYPE html><html><head><title>Article 42</title></head><body><head…
122.9 msHTMLHTML chunk written (14 bytes): </body></html>
123.2 msFizzonAllReady — every boundary content rendered
122.8 ms <!DOCTYPE html><html><head><title>Article 42</title></head><body><header>My site</header><article><h1>Understanding Streams</h1><p>A stream is a queue of Buffers…</p></article><footer>© 2026</footer> 122.9 ms </body></html>

The same page with a boundary around the article

components — Suspense boundary
1function Page() {
2 return (
3 <>
4 <Header />
5 <Suspense fallback={<Skeleton />}>
6 <ArticlePage />
7 </Suspense>
8 <Footer />
9 </>
10 );
11}
12function Skeleton() { return <div className="skeleton">Loading article…</div>; }
L5 — boundaryan element with a symbol type on the wire; behaviour lives in the consumer
L6 — unchangedthe async function is identical to the sibling case
L11 — fallbackran at 0.5 ms, serialised eagerly

Nothing changed for the JavaScript. On the wire the only additions are the outlined symbol row 1:"$Sreact.suspense" and the boundary element wrapping "$L2" together with its fallback. The article row itself is identical, just numbered 2.

What changed is the SSR side: fired as soon as row 0 was decoded, the shell went out with , <template id="B:0"> and the skeleton, and the article arrived later as <div hidden id="S:0"> plus $RC("B:0","S:0"). In Next.js this boundary is what creates.

Executed trace — Flight process, production build, timings from after a warm-up render. Step through the events; rows light up as they are written.

1/17
0 msReactrenderToPipeableStream(<Page/>) called
0.1 msReactrenderToPipeableStream returned — no component has run yet
0.2 msReactpipe(destination) called
0.4 msJSPage() runs synchronously
0.5 msJSHeader() runs synchronously
0.5 msJSSkeleton() runs synchronously (fallback element)
0.5 msJSArticlePage() body starts
0.5 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) called → Promise pending
0.6 msJSFooter() runs synchronously
0.7 mswirerow written: 1:”$Sreact.suspense”
0.7 mswirerow written: 0:[[“$”,”header”,null,{“children”:”My site”}],[“$”,”$1”,null,{“f…
41 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) settled after 40 ms
41 msJSArticlePage() resumes after await; article.title = “Understanding Streams”
41.1 msJSmicrotask queued right after resume runs
41.1 msJSsetImmediate queued right after resume runs
41.2 mswirerow written: 2:[“$”,”article”,null,{“children”:[[“$”,”h1”,null,{“children”:”U…
41.3 msstreamFlight stream closed (every task completed)
0.7 ms 1:"$Sreact.suspense" 0.7 ms 0:[["$","header",null,{"children":"My site"}],["$","$1",null,{"fallback":["$","div",null,{"className":"skeleton","children":"Loading article…"}],"children":"$L2"}],["$","footer",null,{"children":"© 2026"}]] 41.2 ms 2:["$","article",null,{"children":[["$","h1",null,{"children":"Understanding Streams"}],["$","p",null,{"children":"A stream is a queue of Buffers…"}]]}]

SSR process — the same scenario rendered to HTML by Fizz while the Flight rows stream in from a separate process. This clock starts when the child process is spawned, so the first rows land about 80 ms in; read the order and the gaps, not the absolute numbers.

1/11
5 msFlightspawned Flight process (react-server condition)
5.3 msSSRcreateFromNodeStream(flightStream) → thenable model
5.4 msFizzrenderToPipeableStream(<Root/>) called
6.1 msFizzrenderToPipeableStream returned
89.5 msFlightFlight row(s) arrived: 1:”$Sreact.suspense” | 0:[[“$”,”header”,null,{“children”:”My si…
93.4 msFizzonShellReady — everything outside Suspense boundaries is rendered; pipe(res) now
94.4 msHTMLHTML chunk written (290 bytes): <!DOCTYPE html><html><head><title>Article 42</title></head><body><head…
130.6 msFlightFlight row(s) arrived: 2:[“$”,”article”,null,{“children”:[[“$”,…
131.4 msFizzonAllReady — every boundary content rendered
131.8 msHTMLHTML chunk written (978 bytes): <div hidden id=”S:0”><article><h1>Understanding Streams</h1><p>A strea…
131.9 msHTMLHTML chunk written (14 bytes): </body></html>
94.4 ms <!DOCTYPE html><html><head><title>Article 42</title></head><body><header>My site</header><!--$?--><template id="B:0"></template><div class="skeleton">Loading article…</div><!--/$--><footer>© 2026</footer><script id="_R_">requestAnimationFrame(function(){$RT=performance.now()});</script> 131.8 ms <div hidden id="S:0"><article><h1>Understanding Streams</h1><p>A stream is a queue of Buffers…</p></article></div><script>$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;b<a.length;b+=2){var c=a[b],e=a[b+1];null!==e.parentNode&&e.parentNode.removeChild(e);var f=c.parentNode;if(f){var g=c.pr … ,2300>a&&2E3<a?2300-a:$RT+300-a)))):b.parentNode.removeChild(b)};$RC("B:0","S:0")</script> 131.9 ms </body></html>

Two async components, two boundaries, out of order

components — two boundaries
1function Page() {
2 return (
3 <>
4 <Header />
5 <Suspense fallback={<Skeleton />}>
6 <ArticlePage /> {/* 40 ms query */}
7 </Suspense>
8 <Suspense fallback={<p>Loading comments…</p>}>
9 <Comments /> {/* 15 ms query */}
10 </Suspense>
11 <Footer />
12 </>
13 );
14}
15async function Comments() {
16 const list = await db.query("SELECT * FROM comments WHERE article_id = 42");
17 return <ul>{list.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
18}
L6 — task 2suspended first, so it took id 2
L9 — task 3suspended second, id 3 — but its row is written first
L16 — fasterthe comments query answers at 15 ms

Ids follow suspension order; rows follow completion order. Row 3 (comments) was written at 16.1 ms, row 2 (article) at 40.8 ms. Each boundary is independent: on the HTML side S:1 was swapped in before .

This is the concrete meaning of “rows are produced as work becomes available”: there is no moment at which the whole tree exists and is serialised.

Executed trace — Flight process, production build, timings from after a warm-up render. Step through the events; rows light up as they are written.

1/22
0 msReactrenderToPipeableStream(<Page/>) called
0.1 msReactrenderToPipeableStream returned — no component has run yet
0.1 msReactpipe(destination) called
0.3 msJSPage() runs synchronously
0.4 msJSHeader() runs synchronously
0.4 msJSSkeleton() runs synchronously (fallback element)
0.4 msJSArticlePage() body starts
0.4 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) called → Promise pending
0.5 msJSComments() body starts
0.5 msDBdb.query(“SELECT * FROM comments WHERE article_id = 42”) called → Promise pending
0.6 msJSFooter() runs synchronously
0.7 mswirerow written: 1:”$Sreact.suspense”
0.7 mswirerow written: 0:[[“$”,”header”,null,{“children”:”My site”}],[“$”,”$1”,null,{“f…
15.9 msDBdb.query(“SELECT * FROM comments WHERE article_id = 42”) settled after 15 ms
16 msJSComments() resumes after await; 2 comments
16.1 mswirerow written: 3:[“$”,”ul”,null,{“children”:[[“$”,”li”,”1”,{“children”:”first”}…
40.5 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) settled after 40 ms
40.5 msJSArticlePage() resumes after await; article.title = “Understanding Streams”
40.5 msJSmicrotask queued right after resume runs
40.6 msJSsetImmediate queued right after resume runs
40.8 mswirerow written: 2:[“$”,”article”,null,{“children”:[[“$”,”h1”,null,{“children”:”U…
40.8 msstreamFlight stream closed (every task completed)
0.7 ms 1:"$Sreact.suspense" 0.7 ms 0:[["$","header",null,{"children":"My site"}],["$","$1",null,{"fallback":["$","div",null,{"className":"skeleton","children":"Loading article…"}],"children":"$L2"}],["$","$1",null,{"fallback":["$","p",null,{"children":"Loading comments…"}],"children":"$L3"}],["$","footer",null,{"children":"© 2026"}]] 16.1 ms 3:["$","ul",null,{"children":[["$","li","1",{"children":"first"}],["$","li","2",{"children":"second"}]]}] 40.7 ms 2:["$","article",null,{"children":[["$","h1",null,{"children":"Understanding Streams"}],["$","p",null,{"children":"A stream is a queue of Buffers…"}]]}]

SSR process — the same scenario rendered to HTML by Fizz while the Flight rows stream in from a separate process. This clock starts when the child process is spawned, so the first rows land about 80 ms in; read the order and the gaps, not the absolute numbers.

1/13
5 msFlightspawned Flight process (react-server condition)
5.3 msSSRcreateFromNodeStream(flightStream) → thenable model
5.4 msFizzrenderToPipeableStream(<Root/>) called
6.1 msFizzrenderToPipeableStream returned
86.9 msFlightFlight row(s) arrived: 1:”$Sreact.suspense” | 0:[[“$”,”header”,null,{“children”:”My si…
91.4 msFizzonShellReady — everything outside Suspense boundaries is rendered; pipe(res) now
92.6 msHTMLHTML chunk written (364 bytes): <!DOCTYPE html><html><head><title>Article 42</title></head><body><head…
102.1 msFlightFlight row(s) arrived: 3:[“$”,”ul”,null,{“children”:[[“$”,”li”,…
103.2 msHTMLHTML chunk written (927 bytes): <div hidden id=”S:1”><ul><li>first</li><li>second</li></ul></div><scri…
127.5 msFlightFlight row(s) arrived: 2:[“$”,”article”,null,{“children”:[[“$”,…
128 msFizzonAllReady — every boundary content rendered
128.2 msHTMLHTML chunk written (149 bytes): <div hidden id=”S:0”><article><h1>Understanding Streams</h1><p>A strea…
128.2 msHTMLHTML chunk written (14 bytes): </body></html>
92.5 ms <!DOCTYPE html><html><head><title>Article 42</title></head><body><header>My site</header><!--$?--><template id="B:0"></template><div class="skeleton">Loading article…</div><!--/$--><!--$?--><template id="B:1"></template><p>Loading comments…</p><!--/$--><footer>© 2026</footer><script id="_R_">requestAnimationFrame(function(){$RT=performance.now()});</script> 103.2 ms <div hidden id="S:1"><ul><li>first</li><li>second</li></ul></div><script>$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;b<a.length;b+=2){var c=a[b],e=a[b+1];null!==e.parentNode&&e.parentNode.removeChild(e);var f=c.parentNode;if(f){var g=c.previousSibling,h=0;do{if(c&&8===c.nodeType){var d= … ,2300>a&&2E3<a?2300-a:$RT+300-a)))):b.parentNode.removeChild(b)};$RC("B:1","S:1")</script> 128.2 ms <div hidden id="S:0"><article><h1>Understanding Streams</h1><p>A stream is a queue of Buffers…</p></article></div><script>$RC("B:0","S:0")</script> 128.2 ms </body></html>

One component, two sequential awaits

components — two awaits
1async function Waterfall() {
2 const article = await db.query("SELECT * FROM articles WHERE id = 42");
3 const list = await db.query("SELECT * FROM comments WHERE article_id = 42");
4 return (
5 <article>
6 <h1>{article.title}</h1>
7 <ul>{list.map(c => <li key={c.id}>{c.text}</li>)}</ul>
8 </article>
9 );
10}
L2 — first awaitsuspends the context; the second query has not been sent
L3 — second awaitsent only at 71.8 ms, after the first row returned

React sees one promise — the component’s — and creates one task. Both awaits are invisible to it; only the JavaScript continuation knows there were two. The single row was written at 87.6 ms: 40 + 15 plus scheduling, because the second query started only after the first returned.

The task model has no opinion about how many times a function suspends internally. The number of tasks is the number of pending promises the renderer was handed, not the number of keywords.

Executed trace — Flight process, production build, timings from after a warm-up render. Step through the events; rows light up as they are written.

1/16
0 msReactrenderToPipeableStream(<Page/>) called
0.1 msReactrenderToPipeableStream returned — no component has run yet
0.1 msReactpipe(destination) called
0.5 msJSPage() runs synchronously
0.6 msJSHeader() runs synchronously
0.7 msJSWaterfall() body starts
0.7 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) called → Promise pending
0.7 msJSFooter() runs synchronously
0.8 mswirerow written: 0:[[“$”,”header”,null,{“children”:”My site”}],”$L1”,[“$”,”footer…
41.1 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) settled after 40 ms
41.1 msJSWaterfall() resumes after first await
41.1 msDBdb.query(“SELECT * FROM comments WHERE article_id = 42”) called → Promise pending
56.4 msDBdb.query(“SELECT * FROM comments WHERE article_id = 42”) settled after 15 ms
56.4 msJSWaterfall() resumes after second await
56.7 mswirerow written: 1:[“$”,”article”,null,{“children”:[[“$”,”h1”,null,{“children”:”U…
56.8 msstreamFlight stream closed (every task completed)
0.8 ms 0:[["$","header",null,{"children":"My site"}],"$L1",["$","footer",null,{"children":"© 2026"}]] 56.7 ms 1:["$","article",null,{"children":[["$","h1",null,{"children":"Understanding Streams"}],["$","ul",null,{"children":[["$","li","1",{"children":"first"}],["$","li","2",{"children":"second"}]]}]]}]

The same two queries, started together

components — Promise.all
1async function Parallel() {
2 const [article, list] = await Promise.all([
3 db.query("SELECT * FROM articles WHERE id = 42"),
4 db.query("SELECT * FROM comments WHERE article_id = 42"),
5 ]);
6 return (
7 <article>
8 <h1>{article.title}</h1>
9 <ul>{list.map(c => <li key={c.id}>{c.text}</li>)}</ul>
10 </article>
11 );
12}
L2 — one awaitone suspension, resumed after the slower promise
L3 — both sentat 0.6 ms, before anything is awaited

Both queries were issued at 0.6 ms; the comments answered at 16 ms and the article at 40.2 ms; the row was written at 40.5 ms. Same single task, same single row — the difference is entirely inside the async function.

Executed trace — Flight process, production build, timings from after a warm-up render. Step through the events; rows light up as they are written.

1/15
0 msReactrenderToPipeableStream(<Page/>) called
0.1 msReactrenderToPipeableStream returned — no component has run yet
0.1 msReactpipe(destination) called
0.4 msJSPage() runs synchronously
0.5 msJSHeader() runs synchronously
0.6 msJSParallel() body starts
0.6 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) called → Promise pending
0.6 msDBdb.query(“SELECT * FROM comments WHERE article_id = 42”) called → Promise pending
0.7 msJSFooter() runs synchronously
0.8 mswirerow written: 0:[[“$”,”header”,null,{“children”:”My site”}],”$L1”,[“$”,”footer…
16 msDBdb.query(“SELECT * FROM comments WHERE article_id = 42”) settled after 15 ms
40.2 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) settled after 40 ms
40.3 msJSParallel() resumes after Promise.all
40.5 mswirerow written: 1:[“$”,”article”,null,{“children”:[[“$”,”h1”,null,{“children”:”U…
40.6 msstreamFlight stream closed (every task completed)
0.8 ms 0:[["$","header",null,{"children":"My site"}],"$L1",["$","footer",null,{"children":"© 2026"}]] 40.5 ms 1:["$","article",null,{"children":[["$","h1",null,{"children":"Understanding Streams"}],["$","ul",null,{"children":[["$","li","1",{"children":"first"}],["$","li","2",{"children":"second"}]]}]]}]

An async parent that renders an async child

components — nested async
1async function Parent() {
2 const article = await db.query("SELECT * FROM articles WHERE id = 42");
3 return (
4 <section>
5 <h1>{article.title}</h1>
6 <Comments />
7 </section>
8 );
9}
10async function Comments() {
11 const list = await db.query("SELECT * FROM comments WHERE article_id = 42");
12 return <ul>{list.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
13}
L2 — task 1Parent suspends the root walk; "$L1" in row 0
L6 — task 2Comments suspends the retry of task 1; "$L2" in row 1
L11 — starts latethe comments query is sent only when Parent's retry renders it

The chain has two levels. Row 0 holds "$L1"; row 1 — written when Parent’s promise fulfilled — holds the h1 and "$L2"; row 2 arrives when Comments’ promise fulfils. Each placeholder is written by the retry of the task above it.

Note when the child’s query starts: 75.7 ms, not 0.4 ms. An async child cannot begin until its async parent has returned the element that renders it — a waterfall that the tree structure imposes, unlike the one inside a single function.

Executed trace — Flight process, production build, timings from after a warm-up render. Step through the events; rows light up as they are written.

1/18
0 msReactrenderToPipeableStream(<Page/>) called
0.1 msReactrenderToPipeableStream returned — no component has run yet
0.1 msReactpipe(destination) called
0.4 msJSPage() runs synchronously
0.4 msJSHeader() runs synchronously
0.4 msJSParent() body starts
0.4 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) called → Promise pending
0.5 msJSFooter() runs synchronously
0.6 mswirerow written: 0:[[“$”,”header”,null,{“children”:”My site”}],”$L1”,[“$”,”footer…
40.8 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) settled after 40 ms
40.9 msJSParent() resumes after await
41 msJSComments() body starts
41 msDBdb.query(“SELECT * FROM comments WHERE article_id = 42”) called → Promise pending
41.1 mswirerow written: 1:[“$”,”section”,null,{“children”:[[“$”,”h1”,null,{“children”:”U…
56.4 msDBdb.query(“SELECT * FROM comments WHERE article_id = 42”) settled after 15 ms
56.5 msJSComments() resumes after await; 2 comments
56.7 mswirerow written: 2:[“$”,”ul”,null,{“children”:[[“$”,”li”,”1”,{“children”:”first”}…
56.7 msstreamFlight stream closed (every task completed)
0.6 ms 0:[["$","header",null,{"children":"My site"}],"$L1",["$","footer",null,{"children":"© 2026"}]] 41.1 ms 1:["$","section",null,{"children":[["$","h1",null,{"children":"Understanding Streams"}],"$L2"]}] 56.7 ms 2:["$","ul",null,{"children":[["$","li","1",{"children":"first"}],["$","li","2",{"children":"second"}]]}]

A synchronous component reading the promise with use()

components — use() instead
1function ArticleUse() {
2 const article = use(db.query("SELECT * FROM articles WHERE id = 42"));
3 return <article><h1>{article.title}</h1></article>;
4}
L2 — runs twiceinvocation #1 suspends; invocation #2 reads the tracked thenable
L2 — second queryissued by invocation #2 and abandoned

The trace shows ArticleUse() starting twice and being called twice. With the parked task’s model is the element, so the retry re-invokes the component and hands back the first promise’s value at the same call index; the second promise is never awaited by anyone.

With the model is the lazy around the component’s own promise and the function runs once. Same task, same row, different re-entry.

Executed trace — Flight process, production build, timings from after a warm-up render. Step through the events; rows light up as they are written.

1/16
0 msReactrenderToPipeableStream(<Page/>) called
0.1 msReactrenderToPipeableStream returned — no component has run yet
0.1 msReactpipe(destination) called
0.4 msJSPage() runs synchronously
0.4 msJSHeader() runs synchronously
0.5 msJSArticleUse() body starts (invocation #3)
0.5 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) called → Promise pending
0.5 msJSFooter() runs synchronously
0.6 mswirerow written: 0:[[“$”,”header”,null,{“children”:”My site”}],”$L1”,[“$”,”footer…
38.8 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) settled after 40 ms
41.1 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) settled after 40 ms
41.2 msJSArticleUse() body starts (invocation #4)
41.2 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) called → Promise pending
41.3 msJSArticleUse() reached the line after use() (invocation #4); title = “Understanding Streams”
41.5 mswirerow written: 1:[“$”,”article”,null,{“children”:[“$”,”h1”,null,{“children”:”Un…
41.5 msstreamFlight stream closed (every task completed)
0.6 ms 0:[["$","header",null,{"children":"My site"}],"$L1",["$","footer",null,{"children":"© 2026"}]] 41.5 ms 1:["$","article",null,{"children":["$","h1",null,{"children":"Understanding Streams"}]}]

An async function with nothing to await

components — no await at all
1async function NoAwait() {
2 return <p>no await inside</p>;
3}
L1 — still a promisefulfilled by a microtask, never synchronously
L2 — still a task"$L1" in row 0; row 1 at 1 ms

The renderer classifies by return value, and an async function always returns a promise that is not yet fulfilled at return time. So NoAwait still becomes task 1 with a placeholder in row 0; its row follows a moment later: the promise fulfils in a microtask, and because the request is still in its opening pass the retry is scheduled as a microtask too ( only reaches for once the request has left OPENING).

Marking a Server Component async is therefore not free of consequences on the wire — it always produces a separate row.

Executed trace — Flight process, production build, timings from after a warm-up render. Step through the events; rows light up as they are written.

1/10
0 msReactrenderToPipeableStream(<Page/>) called
0.1 msReactrenderToPipeableStream returned — no component has run yet
0.1 msReactpipe(destination) called
0.4 msJSPage() runs synchronously
0.6 msJSHeader() runs synchronously
0.6 msJSNoAwait() body runs to its return with no await
0.7 msJSFooter() runs synchronously
0.7 mswirerow written: 0:[[“$”,”header”,null,{“children”:”My site”}],”$L1”,[“$”,”footer…
1 mswirerow written: 1:[“$”,”p”,null,{“children”:”no await inside”}]
1 msstreamFlight stream closed (every task completed)
0.7 ms 0:[["$","header",null,{"children":"My site"}],"$L1",["$","footer",null,{"children":"© 2026"}]] 1 ms 1:["$","p",null,{"children":"no await inside"}]

The database rejects inside a boundary

components — the row rejects
1async function ArticlePage() {
2 const article = await db.query(...); // the driver rejects: connection reset
3 return <article>…</article>; // never reached
4}
5// Flight options passed by the probe
6renderToPipeableStream(<Page />, {}, {
7 onError(err) { return 'digest-reject'; }, // only the digest crosses the wire
8});
L2 — throwsthe rejection surfaces at the await site inside the function
L7 — digestwhat the client will see

The rejection rejects the component’s promise; still fires (it was subscribed for both outcomes), the retry reads the lazy, throws the reason, and the task errors: 2:E{"digest":"digest-reject"}. The message stays on the server in production.

Fizz had already sent the shell with the skeleton. When the row arrived it emitted $RX("B:0","digest-reject"), marking the boundary client-rendered: the fallback stays, hydration retries the boundary on the client, the error is thrown there and reaches the nearest error boundary — in the App Router.

Executed trace — Flight process, production build, timings from after a warm-up render. Step through the events; rows light up as they are written.

1/15
0 msReactrenderToPipeableStream(<Page/>) called
0.1 msReactrenderToPipeableStream returned — no component has run yet
0.2 msReactpipe(destination) called
0.5 msJSPage() runs synchronously
0.6 msJSHeader() runs synchronously
0.6 msJSSkeleton() runs synchronously (fallback element)
0.7 msJSArticlePage() body starts
0.7 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) called → Promise pending
0.8 msJSFooter() runs synchronously
0.9 mswirerow written: 1:”$Sreact.suspense”
0.9 mswirerow written: 0:[[“$”,”header”,null,{“children”:”My site”}],[“$”,”$1”,null,{“f…
41.1 msDBdb.query(“SELECT * FROM articles WHERE id = 42”) settled after 40 ms
41.2 msReactonError(“connection reset”) → returns digest
41.3 mswirerow written: 2:E{“digest”:”digest-reject”}
41.3 msstreamFlight stream closed (every task completed)
0.9 ms 1:"$Sreact.suspense" 0.9 ms 0:[["$","header",null,{"children":"My site"}],["$","$1",null,{"fallback":["$","div",null,{"className":"skeleton","children":"Loading article…"}],"children":"$L2"}],["$","footer",null,{"children":"© 2026"}]] 41.3 ms 2:E{"digest":"digest-reject"}

SSR process — the same scenario rendered to HTML by Fizz while the Flight rows stream in from a separate process. This clock starts when the child process is spawned, so the first rows land about 80 ms in; read the order and the gaps, not the absolute numbers.

1/12
2.9 msFlightspawned Flight process (react-server condition)
3.1 msSSRcreateFromNodeStream(flightStream) → thenable model
3.2 msFizzrenderToPipeableStream(<Root/>) called
4 msFizzrenderToPipeableStream returned
84.8 msFlightFlight row(s) arrived: 1:”$Sreact.suspense” | 0:[[“$”,”header”,null,{“children”:”My si…
89.1 msFizzonShellReady — everything outside Suspense boundaries is rendered; pipe(res) now
90.4 msHTMLHTML chunk written (290 bytes): <!DOCTYPE html><html><head><title>Article 42</title></head><body><head…
121.2 msFlightFlight row(s) arrived: 2:E{“digest”:”digest-reject”}
121.6 msFizzonError(“An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.”)
121.8 msFizzonAllReady — every boundary content rendered
121.9 msHTMLHTML chunk written (244 bytes): <script>$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b…
122 msHTMLHTML chunk written (14 bytes): </body></html>
90.4 ms <!DOCTYPE html><html><head><title>Article 42</title></head><body><header>My site</header><!--$?--><template id="B:0"></template><div class="skeleton">Loading article…</div><!--/$--><footer>© 2026</footer><script id="_R_">requestAnimationFrame(function(){$RT=performance.now()});</script> 121.9 ms <script>$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,null!=c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};;$RX("B:0","digest-reject")</script> 122 ms </body></html>

In Next.js

The App Router runs exactly these two renderers per document request and only the first per navigation. Nothing on this page is Next-specific except who calls what, and where the boundary comes from.

['', '', '', '', '', '', '', '', '']Diagram: the App Router’s document request, client navigation and sources of a boundaryDOCUMENT REQUEST — GET /ARTICLES/42route treelayouts → pageFlight renderreact-server graphFizz renderconsumes the rowsHTML + inline rowsself.__next_f.pushtwo renderers in one request: Flight produces rows, Fizz turns them into streamed HTML and inlines them for hydrationCLIENT NAVIGATION — FETCH WITH RSC: 1Link clickstartTransitionFlight rendersame server codeFlight clientcreateFromFetchFiber commitboundaries suspendno HTML at all: the browser decodes rows straight into elements; "$L…" holes suspend inside the nearest boundaryWHERE THE BOUNDARY COMES FROMloading.tsxSuspense around the segment<Suspense> in your JSXany finer boundaryno boundarythe shell or the navigation waits
piecewhowhat it does
route treeNext.jslayouts → page, with / boundaries inserted; the root layout renders synchronously
Flight renderReact, react-server graph; async pages suspend into tasks and rows exactly as above
SSRReact (Fizz), client graph decodes a d copy of the rows; starts the HTML stream; boundaries are patched with
inline rowsNext.jsthe other copy is embedded as script chunks for hydration
navigationNext.js router + Fiberfetch with , decode with , commit inside
the fallbackyou or an explicit ; without one the shell, or the navigation, waits

Reference

The four verbs

verblevelmechanismfunction
suspendsJavaScriptthe execution context leaves the stack at ; the call evaluates to a pending promiseengine
parksReactthe thrown promise is caught; a task with the lazy as model is created; is written catch
resumesJavaScript, then Reactthe continuation runs as a microtask; schedules
foldsJavaScriptJSX evaluation reads the row while constructing the elementsthe component’s own code

One word per concept

wordmeans
taskReact’s unit of server rendering work; owns one row id
rowone line of the Flight response, :
placeholderthe string written where a suspended node belongs
continuationthe rest of the async function after an , run as a microtask
boundarya element; the only source of a fallback
chunkon the client, the decoded unit per row; in Fizz, one write of HTML

How the evidence was captured

Two probes, both in this container. The Flight probe runs a scenario through with faked by timers, logging every component entry, every resumption and every row as it is written; a warm-up render runs first so the measured run pays no first-call compilation cost. The SSR probe spawns that same render in a child process with the condition and feeds its stdout to and Fizz.

flight-probe.js (condensed)
1// flight-probe.js
2// NODE_ENV=production node --conditions=react-server flight-probe.js <scenario>
3const { renderToPipeableStream } =
4 require('react-server-dom-webpack/server.node');
5const { scenarios, h } = require('./components.js');
6function Page() {
7 log('js', 'Page() runs synchronously');
8 return scenarios[name]();
9}
10
11const sink = new Writable({
12 write(chunk, enc, done) {
13 chunk.toString('utf8').split('\n').filter(Boolean).forEach(line => {
14 rows.push({ t: ms(), text: line });
15 log('wire', 'row written: ' + line.slice(0, 64));
16 });
17 done();
18 },
19 final(done) {
20 log('stream', 'Flight stream closed (every task completed)');
21 done();
22 },
23});
24log('react', 'renderToPipeableStream(<Page/>) called');
25const { pipe } = renderToPipeableStream(h(Page), {}, {
26 onError(err) { return 'digest-' + name; },
27});
28log('react', 'renderToPipeableStream returned — no component has run yet');
29pipe(sink);
L4 — serverthe Node-streams entry point of the same Flight server Next.js calls
L13 — rowsevery line the renderer writes, with its time
L25 — digestwhat the E row carries in production
Built from executed probes against react 19.3.0, react-dom 19.3.0, react-server-dom-webpack 19.3.0 on Node v22.22.2. Scheduling details ( for retries, a microtask for the first pass) are those of the Node stream build; the edge build differs only in the primitive, not in the order. No section of this page describes behaviour that the traces above do not show, except the browser-side navigation behaviour, which is stated from React’s transition semantics.
Back to JIT-Atlas