The function suspends on await; 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 await, what the Flight server does with the promise it is handed, what reaches the wire and when, and what a Suspense boundary changes for whoever consumes the result. Every timing and every row in the traces below was captured from the real renderer, not sketched.
evidence: React 19.3.0, react-server-dom-webpack 19.3.0, Node v22.22.2, production buildscompanion to Two Renderers, One Requestclick any codechip 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.
JavaScript level: calling ArticlePage runs its body until the first await, suspends the execution context, and evaluates to a pending Promise. The continuation runs as a microtask when the awaited promise settles.
React level: renderFunctionComponent receives that promise instead of elements, wraps it, and the wrapper throws it out of the serialisation walk; renderModel catches it, creates a task, subscribes ping, and writes a placeholder.
Neither level holds the thread. Between the suspension and the resumption the process serves other requests, renders the component’s siblings and retries any other task whose promise settled.
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
1asyncfunctionArticlePage(){
2constarticle=awaitdb.query("SELECT * FROM articles WHERE id = 42");
3return(
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 article bound
L5 — foldarticle.title is read while this element is constructed
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 article bound
L5 — foldarticle.title is read while this element is constructed
React invokes the Server Component
The renderer effectively does constresult=ArticlePage(props). Because the function is async, the call does not produce the element tree; it produces a promise, while the body has already started executing.
It reaches await
db.query sends the query and returns a promise that the database has not fulfilled yet. The function cannot continue to its return because article does not exist yet.
What “suspends” means
The execution context of the async function is suspended: it is removed from the callstack, 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.
Three requests in flight on one thread: A is parked at its await while B and C are rendered. When the row returns, A resumes. “Suspends” describes the async computation, never the thread.
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.
The database promise resolves
Fulfilment queues the continuation. When it runs, awaitdb.query(...) evaluates to the row object, article is bound, and execution resumes at the next line.
Now the element tree is constructed
This is “folds the data into the returned elements”: the JSX expressions read article.title and article.body while the elements are built. The data becomes part of the tree the component returns.
The invariant. The element tree is not created with missing article 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
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.
93task.status=PENDING;// the root task waits in place
94x.then(task.ping,task.ping);
95}elseerroredTask(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 — subscribeping 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
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 — subscribeping 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:
Detection is by return value.processServerComponentReturnValue sees a thenable and wraps it in a lazy element (createLazyWrapperAroundWakeable). The renderer never inspects the function itself.
“Parking” is a catch block.readThenable throws the pending promise; renderModel catches it, calls createTask with the lazy as the model, subscribes ping, and returns "$L1". JSON.stringify then moves on to the next child.
The continuation resumes first, then React reacts. On the component’s promise two reactions are registered in order: the status tracker, then ping. Both are microtasks; pingTask only schedules performWork with setImmediate.
The retry does not call the component again.retryTask renders the task’s model — the lazy — and readThenable now returns the element tree. emitChunk serialises it as row 1; flushCompletedChunks writes it.
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.
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.
Client Component
async Server Component
renders
twice: loading UI, then with data
once, after await
where the data waits
component state
a local binding in a suspended execution context
who triggers the second pass
setState after fetch
nobody — the continuation finishes the only pass
what the consumer receives
a tree that changes later
a 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.
Page() returns three React elements — descriptions, not DOM. The root task serialises that array with JSON.stringify, invoking each component as the walk reaches it. When it reaches ArticlePage, the promise comes back pending and the task below is created.
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: renderModel returned a string, so JSON.stringify visits the next array element. Footer 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 ArticlePage() returned immediately.
1/5
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 (D 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 ArticlePage is parked there may be no browser-visible result at all. React does not invent a placeholder for a component that has none. A Suspense boundary is the only source of a fallback, and it changes nothing at the two levels above — only what a consumer may show before the row arrives.
The boundary is an ordinary element to the Flight server: its type is Symbol.for("react.suspense"), serialised as "$Sreact.suspense" in its own row and referenced from the boundary element, whose children is the placeholder "$L2". The decision is made by whoever renders that element.
What a boundary changes, level by level
level
without a boundary
with a boundary
JavaScript
identical: one call, one suspension, one continuation
identical
React tasks
task 1, placeholder "$L1"
task 2, placeholder "$L2"; the symbol takes id 1
Flight rows
row 0 at 0.6 ms, row 1 at 41.2 ms
rows 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 1
shell at onShellReady with the fallback; S:0 + $RC later
Fiber (navigation)
transition stays pending; old page stays
fallback shows in the new boundary
error
nothing isolates it: the shell errors (onShellError); on a navigation it reaches the nearest error.tsx
$RX marks the boundary client-rendered; error.tsx 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.
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.
In the App Router the boundary usually comes from loading.tsx: Next.js wraps the segment’s page in <Suspensefallback={<Loading/>}> when that file exists. Without it, and without your own Suspense, an async page.tsx 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, ArticlePage has not reached its return, 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.
“Finalized” can mean three different things, and they happen at three different moments.
finalized…
when
evidence
A · the component’s returned tree
the async function returns
continuation at 40.9 ms, promise fulfilled in the same microtask turn
B · the complete render tree
every task has completed
abortableTasks empty → onAllReady
C · the Flight response
the stream closes — rows were written as tasks completed
row 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.
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.
A task moves through a small state machine. The parked async component moves through it once: created PENDING, retried, completed. The root task suspends in place instead of spawning a child, and a component using use can bounce several times if it reads several promises.
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.
poll: the driver’s socket read resolves the query promise (in the probe, a timer callback).
microtasks: the continuation runs and returns; the component’s promise fulfils; its reactions run in registration order — status tracker, then ping, which calls setImmediate.
check: performWork retries the task, emitChunk builds the row, flushCompletedChunks writes it. The probe’s own setImmediate, queued by the continuation, ran 0.3 ms before the row was written.
Flight is not “the element tree serialised once at the end”
The picture to discard first.
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.
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.
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.
Rows, not a tree
Flight is not JSON.stringify(entireReactElementTree). It is a protocol of typed rows and references; the client keeps a table of chunks by id and reconstructs elements as rows arrive.
seen in the probes
meaning
example
"$"
element marker at the head of an element array
[“$”,”article”,null,{…}]
"$L…"
lazy reference to another row — a suspendable hole
“$L1”, “$L2”, “$L3”
"$…"
plain reference to another row — blocks the containing row
“$1” → the Suspense symbol
"$S…"
registry symbol
“$Sreact.suspense”
E
error row — digest only in production
2:E{“digest”:”digest-reject”}
D
debug info (dev builds), plus J for awaited async work
5: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.
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
1exportdefaultasyncfunctionArticlePage(){
2constarticle=awaitdb.query("SELECT * FROM articles WHERE id = 42");
3return(
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
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 (retryTask‘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 setImmediate the continuation itself queued.
Executed trace — Flight process, production build, timings from performance.now() 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 ms 0:["$","article",null,{"children":[["$","h1",null,{"children":"Understanding Streams"}],["$","p",null,{"children":"A stream is a queue of Buffers…"}]]}]
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
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. Footer() ran 0.1 ms after ArticlePage() 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 performance.now() 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
SSR process — the same scenario rendered to HTML by Fizz while the Flight rows stream in from a separate react-server 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
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
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: onShellReady fired as soon as row 0 was decoded, the shell went out with <!--$?-->, <templateid="B:0"> and the skeleton, and the article arrived later as <divhiddenid="S:0"> plus $RC("B:0","S:0"). In Next.js this boundary is what loading.tsx creates.
Executed trace — Flight process, production build, timings from performance.now() 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
SSR process — the same scenario rendered to HTML by Fizz while the Flight rows stream in from a separate react-server 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
L9 — task 3suspended second, id 3 — but its row is written first
L16 — fasterthe comments query answers at 15 ms
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 S:0.
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 performance.now() 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
SSR process — the same scenario rendered to HTML by Fizz while the Flight rows stream in from a separate react-server 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
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
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 await keywords.
Executed trace — Flight process, production build, timings from performance.now() 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
L2 — one awaitone suspension, resumed after the slower promise
L3 — both sentat 0.6 ms, before anything is awaited
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 performance.now() 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
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
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 performance.now() 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
L2 — second queryissued by invocation #2 and abandoned
The trace shows ArticleUse() starting twice and db.query being called twice. With use() the parked task’s model is the element, so the retry re-invokes the component and thenableState hands back the first promise’s value at the same call index; the second promise is never awaited by anyone.
With await 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 performance.now() 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
L1 — still a promisefulfilled by a microtask, never synchronously
L2 — still a task"$L1" in row 0; row 1 at 1 ms
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 (pingTask only reaches for setImmediate 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 performance.now() 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
2constarticle=awaitdb.query(...);// the driver rejects: connection reset
3return<article>…</article>;// never reached
4}
5// Flight options passed by the probe
6renderToPipeableStream(<Page/>,{},{
7onError(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
L2 — throwsthe rejection surfaces at the await site inside the function
L7 — digestwhat the client will see
The rejection rejects the component’s promise; ping still fires (it was subscribed for both outcomes), the retry reads the lazy, readThenable 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 E 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 — error.tsx in the App Router.
Executed trace — Flight process, production build, timings from performance.now() 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
SSR process — the same scenario rendered to HTML by Fizz while the Flight rows stream in from a separate react-server 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
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>
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.
piece
who
what it does
route tree
Next.js
layouts → page, with loading.tsx / error.tsx boundaries inserted; the root layout renders <html>/<body> synchronously
Flight render
React, react-server graph
renderToReadableStream; async pages suspend into tasks and rows exactly as above
SSR
React (Fizz), client graph
createFromReadableStream decodes a tee()d copy of the rows; onShellReady starts the HTML stream; boundaries are patched with $RC
inline rows
Next.js
the other copy is embedded as self.__next_f.push(…) script chunks for hydration
navigation
Next.js router + Fiber
fetch with RSC:1, decode with createFromFetch, commit inside startTransition
the fallback
you
loading.tsx or an explicit Suspense; without one the shell, or the navigation, waits
Reference
The four verbs
verb
level
mechanism
function
suspends
JavaScript
the execution context leaves the stack at await; the call evaluates to a pending promise
engine
parks
React
the thrown promise is caught; a task with the lazy as model is created; "$L1" is written
renderModel catch
resumes
JavaScript, then React
the continuation runs as a microtask; ping schedules performWork
pingTask
folds
JavaScript
JSX evaluation reads the row while constructing the elements
the component’s own code
One word per concept
word
means
task
React’s unit of server rendering work; owns one row id
row
one line of the Flight response, id:json
placeholder
the "$L…" string written where a suspended node belongs
continuation
the rest of the async function after an await, run as a microtask
boundary
a Suspense element; the only source of a fallback
chunk
on 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 renderToPipeableStream with db.query faked by timers, logging every component entry, every await 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 react-server condition and feeds its stdout to createFromNodeStream and Fizz.
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
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 (setImmediate 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.
An asyncfunction is an ordinary function with one extra rule: the call expression evaluates to a promise, and the body may pause at await. Everything before the first await runs synchronously on the caller’s stack — in the probes, ArticlePage() started and issued its query in the same 0.4 ms in which Page() and Header() ran.
callruns the body until the first await, then returns a pending promisereturnfulfils that promise with the returned element treethrowrejects it; Flight turns the rejection into an E rowno awaitstill returns a promise, so the component is still a task boundary (see the “no await at all” scenario)
ArticlePage.tsx
1asyncfunctionArticlePage(){
2// synchronous part: runs on the renderer's stack
3constarticle=awaitdb.query("SELECT * FROM articles WHERE id = 42");
4// continuation: runs as a microtask after the row returns
5return(
6<article>
7<h1>{article.title}</h1>
8<p>{article.body}</p>
9</article>
10);
11}
L1 — callthe call returns a pending promise at line 3
L3 — suspendthe execution context leaves the stack here
L5 — resumeevaluated later, with article bound
L1 — callthe call returns a pending promise at line 3
L3 — suspendthe execution context leaves the stack here
awaitv resolves v to a promise, subscribes the function’s continuation to it, suspends the execution context and removes it from the call stack. Control returns to the caller — here renderFunctionComponent — which receives the async function’s own promise.
The Node.js thread is not blocked. The event loop keeps running: the same pass renders Footer()0.1 ms later, and other requests are served while the row is out.
resumesvia a promise reaction job — a microtask — after the operand settlesrejectsa rejected operand throws at the await site inside the functioncostone microtask turn per await of a native promise
“Suspends” describes the async computation, not the server. The function’s execution context is detached from the stack; its local bindings live on in its environment record, referenced by the pending reaction. The stack unwinds normally into React’s renderer, which now holds a promise.
Two different things are therefore “waiting”: the JavaScript continuation (owned by the engine’s promise machinery) and the Flight task (owned by React’s request). Neither occupies the thread.
JS levelcontext suspended; resumed by a microtaskReact leveltask marked pending; retried by performWorkthreadfree: siblings, other tasks, other requests all run
Flight never inspects the database promise. It only ever sees the component’s own promise — the one ArticlePage() returned — and it tags that object with a status field so a retry can read it synchronously.
ReactFlightServer.js
1// what the lazy wrapper does to the component's promise (19.3.0, condensed)
L3 — reaction #1registered before ping, so it runs first
L2 — tagthe promise object itself is mutated
L3 — reaction #1registered before ping, so it runs first
the query promiseawaited by the function; React never touches itthe component promisetagged, wrapped in a lazy, subscribed with pingreaction orderstatus tracker, then ping — both microtasks
When the query promise fulfils, the engine enqueues a promise reaction job. When that microtask runs, the suspended context is pushed back on the stack and execution continues at the line after await with article bound to the row. The JSX evaluates; return fulfils the component’s promise.
In the probe the row settled at 40.8 ms and the continuation logged at 40.9 ms. React’s ping ran next, and the retry that wrote row 1 landed at 41.2 ms — after a setImmediate queued by the continuation itself.
flight-probe.js
1// ordering probe, placed right after the await in ArticlePage
2log('ArticlePage() resumes after await');
3queueMicrotask(()=>log('microtask queued right after resume runs'));// 40.9 ms
4setImmediate(()=>log('setImmediate queued right after resume runs'));// 40.9 ms
5// React's row 1 was written at 41.2 ms — after both of these
In an async function, returnvalue calls the internal resolve function of the promise the call produced. The returned value here is the element tree, so the promise’s value is exactly what a synchronous component would have returned directly.
fulfilthe element tree becomes wakeable.value via the status trackerthenping runs, schedules the retryretryreadThenable returns the value; it is serialised like any element
“Folds the data into the returned elements” is not a React operation. It is JSX evaluation: <h1>{article.title}</h1> compiles to a call that reads article.title at that moment and stores the string in props.children of the h1 element.
ArticlePage.compiled.js
1// what the JSX compiles to (React automatic runtime)
2import{jsx}from'react/jsx-runtime';
3returnjsx('article',{
4children:[
5jsx('h1',{children:article.title}),// "Understanding Streams" is read here
6jsx('p',{children:article.body}),
7],
8});
L5 — evaluatedan ordinary property read on an ordinary object
L5 — evaluatedan ordinary property read on an ordinary object
Because the read happens after await, the element is never created with a hole. There is no later “patch the title in” step anywhere in the pipeline.
The database result is a plain object once the promise fulfils: {id:42,title:"Understanding Streams",body:"…"}. It has no special status in React; it is bound to article like any const.
livesin the async function’s environment record until the function returnscrosses the wireonly the parts that were folded into elements — title and body as children strings; id never leaves the serverdev buildsthe awaited value is additionally forwarded as debug info (J/D rows)
Whatever the driver, the shape is the same: the call sends the query and returns a pending promise; a socket read delivered by the event loop fulfils it later. The probes replace the driver with setTimeout so the timings are deterministic.
components.js
1functionquery(sql,delay,result,shouldReject){
2log('db',`db.query(${JSON.stringify(sql)}) called → Promise pending`);
3returnnewPromise((resolve,reject)=>{
4setTimeout(()=>{
5log('db',`db.query(${JSON.stringify(sql)}) settled after ${delay}ms`);
The renderer does not know a component is async until it looks at the return value. renderFunctionComponent calls Component(props); the result is then classified by processServerComponentReturnValue.
A Flight task is rendered by JSON.stringify(model,task.toJSON). The replacer, renderModel, is where components get invoked, elements become arrays and suspensions are caught. Because it is one depth-first JSON walk, “React continues with the other children” needs no scheduler: the replacer returns a placeholder string and JSON.stringify visits the next array item.
task.modelset to each value as it is entered; inherited by a new task on suspensionelementsencoded as ["$",type,key,props]key-less fragmentsflattened to their children array
A pending promise cannot be serialised as children, so Flight wraps it in the same shape React.lazy uses: a lazy element whose payload is the promise and whose _init is readThenable. Rendering the lazy means calling _init(_payload).
If the promise were already fulfilled the value would be returned directly — but an async function’s promise is never fulfilled at the moment it is returned, so an async component always takes the lazy path.
Three lines decide everything. While the status is pending, reading the lazy throws the promise; the throw unwinds through renderFunctionComponent and the element’s renderModelDestructive into the nearest renderModel catch.
4throwthenable;// pending: throw the promise so the renderer can subscribe to it
5}
L2 — retrythe value is the element tree
L4 — suspendcaught by renderModel or retryTask
L2 — retrythe value is the element tree
L4 — suspendcaught by renderModel or retryTask
at a child positionrenderModel catches it → new task + "$L…"at the rootretryTask catches it → the root task waits in placerejectedthe reason propagates → E row
The unit of rendering work on the server. A request starts with one root task (id 0) and grows a task for every suspension caught at a child position. Each task owns one row id and, when it completes, writes one row.
idrequest.nextChunkId++ — the row id, written in hexstatusPENDING → RENDERING → COMPLETED (or ERRORED/ABORTED)modelthe value to render — an element, a lazy, an array, an objectpingthe closure subscribed to thrown thenableskeyPath / implicitSlotkey context so the client can reconcile children correctlythenableStatetracked use() thenables for re-invocation
“The renderer parks it” is this catch block. It creates a task whose model is the value that suspended (the lazy around the component’s promise), subscribes the task’s ping to that promise, and returns a reference string into the parent’s JSON. For a React node the string is "$L"+id, a lazy reference that will not block the parent row.
Three different things continue, at three granularities:
the same walkthe next siblings — Footer() ran 0.1 ms after the suspension, in the same synchronous passother tasksany task whose promise has settled is retried by the next performWork passother requeststhe event loop serves them; nothing here holds the thread
What does not happen: React does not jump into another component’s call stack, and it does not poll. The thrown promise unwound the stack; the only bookkeeping is a subscription.
One pass over the tasks that were pinged since the last pass, followed by a flush. The first pass is scheduled as a microtask by startWork; later passes are scheduled by pingTask with setImmediate in the Node build.
ping is the task’s wake-up call. It is subscribed to the thrown promise for both outcomes — x.then(ping,ping) — because a rejection must also retry the task so the rejection can be serialised as an E row.
It runs as a microtask right after the status tracker, and it only schedules: pingTask queues the task and, when the queue was empty, schedules a performWork pass. That is why the row is written in a later setImmediate turn rather than inside the reaction.
runsmicrotask, after the continuation and the status trackerdoespush to pingedTasks; schedule performWorkdoes notrender, serialise or write
The retry renders the task’s model again. For a parked async component the model is the lazy wrapper, so the retry calls readThenable and now gets the element tree — the component function is not called a second time. The tree is serialised and the row emitted.
Header and Footer return elements, not promises. processServerComponentReturnValue passes the element straight on, and it is encoded inline into whatever row is being written. They never become tasks and never get a row of their own in production builds.
productioninline in the parent row: 0:[["$","header",…],"$L1",["$","footer",…]]developmentoutlined into its own row so debug info (D rows) can attach: 0:["$2","$L5","$7"]rulerows come from tasks; tasks come from the root and from suspensions
What a component returns: nested plain objects {$$typeof,type,key,props}. For an async component the tree is constructed after await, in one go, with the data already in it. It is a description — “render an article with these children” — not DOM and not yet wire rows.
8{$$typeof:…,type:'p',key:null,props:{children:'A stream is a queue…'}},
9],
10},
11}
L7 — foldedthe string was read from the row while building this
L7 — foldedthe string was read from the row while building this
Three “finalized” moments follow from this: the component’s result exists when the function returns; the page’s tree is complete when every task has completed; the Flight response is a stream that closes at that same moment.
A document request for /articles/42 arrives at Next.js. The App Router resolves the route tree (layouts, the page, any loading/error files), builds the element tree for it, and hands that tree to the Flight server. In the probes the same role is played by renderToPipeableStream(<Page/>).
renderFlight in the react-server module graphSSRFizz consumes the rows and streams HTMLinlinethe rows are also embedded as self.__next_f.push chunks
react-server-dom-webpack/server: a request object holding tasks, a row-id counter, queues of completed rows and a destination. It runs components, turns their output into rows and flushes rows as tasks complete. It has no notion of Suspense fallbacks — that is the consumer’s job.
One line of the response: <id>:<optionaltag><json>\n. Untagged rows are models; E is an error, I a client-reference import, T a long text, D/J debug info in dev builds. Element arrays inside a model are ["$",type,key,props].
1:["$","article",null,{"children":[["$","h1",null,{"children":"Understanding Streams"}],["$","p",null,{"children":"A stream is a queue of Buffers…"}]]}]
row 0written at 0.6 ms, with the placeholderrow 1written at 41.2 ms, when task 1 completedorderrows are written in completion order, which need not be id order
Ids come from one counter per request, nextChunkId, and are written in hexadecimal. They are allocated in creation order: the root gets 0, the first suspension 1, an outlined symbol such as $Sreact.suspense takes one too. Completion order is a different thing entirely.
two boundaries scenarioids 2 (article) and 3 (comments) were allocated in that order; row 3 was written firstdev buildsids run past f quickly — 10, 11 … are hexthe trailing newlineeach row is one line; the client splits on \n
["$",type,key,props]. "$" marks an element; type is a tag name string, a "$S…" symbol reference (Suspense, Fragment with a key) or a "$L…"/"$…" row reference for a client component; props is a JSON object whose children are more elements, strings or references. Development builds append owner and stack fields.
Both refer to another row; they differ in what the client does before that row arrives. "$L2" becomes a lazy element in place, so the containing row resolves immediately and the hole suspends only when rendered — inside whatever Suspense boundary encloses it. "$2" is a plain reference: the containing row cannot resolve until row 2 exists.
ReactFlightClient.js
1// Flight client (19.3.0, condensed) — parsing a "$…" string in a model
When a task’s model is rendered without suspending, emitChunk serialises it: JSON.stringify(resolvedModel,task.toJSON) — components inside run during this call — and the row is queued as id.toString(16)+":"+json+"\n". The flush at the end of performWork writes it.
stringsinlined unless long, then a T rowtyped arrays / blobsbinary rows with their own tagsshared objectsoutlined once and referenced by "$id"
Completed rows sit in queues until flushCompletedChunks writes them to the destination — at the end of every performWork pass. The stream closes when abortableTasks is empty, i.e. when the last task has completed: “the Flight response is finalized” means exactly this.
siblings scenariorow 0 flushed at 0.6 ms; row 1 at 41.2 ms; closed at 41.2 mstwo boundariesrow 3 flushed at 16.1 ms, row 2 at 40.8 ms — completion orderbackpressureif the destination is full, flushing pauses until drain
The network response is the Flight stream itself. It is not produced at the end; it is written to as tasks complete and closed when none remain. On the SSR side, Fizz’s HTML response ends when its own boundaries have all completed — after the last Flight row it needed.
Flightcloses on abortableTasks.size===0FizzonAllReady, then </body></html>navigationthe browser’s Flight client sees the stream end and marks remaining chunks errored if any are still pending
To the Flight server a boundary is an element like any other: type Symbol.for("react.suspense"), serialised as ["$","$1",null,{"fallback":…,"children":"$L2"}]. The behaviour lives in the consumers: Fizz turns it into a shell with a fallback and a later $RC swap; the browser’s Fiber renderer shows the fallback while the lazy hole is pending.
changes at the JS levelnothing — the async function is identicalchanges on the wireone extra row for the symbol and the boundary element around the holechanges for the consumereverything — what can be shown before row 2 arrives
page.tsx
1exportdefaultfunctionPage(){
2return(
3<>
4<Header/>
5<Suspensefallback={<Skeleton/>}>
6<ArticlePage/>
7</Suspense>
8<Footer/>
9</>
10);
11}
L5 — boundarythe consumer may show Skeleton while row 2 is pending
L5 — boundarythe consumer may show Skeleton while row 2 is pending
The fallback is rendered eagerly on the server — Skeleton() ran during the first pass — and travels in the boundary element’s fallback prop. Fizz writes it into the shell inside the boundary markers; Fiber shows it when a new boundary’s content suspends.
A Client Component cannot await during render. It renders once without data, shows loading UI, starts a fetch from useEffect, stores the result with setState and renders again. Two renders, one of them with holes.
An async Server Component has one render, after the data. What used to be state on the client is a local binding in a suspended function on the server.
The boundary element is serialised eagerly with its fallback and a lazy reference for its content. The symbol type is outlined into its own row, which is why the first write of the suspense scenario contains two rows.
2:["$","article",null,{"children":[["$","h1",null,{"children":"Understanding Streams"}],["$","p",null,{"children":"A stream is a queue of Buffers…"}]]}]
row 1the symbol, written before row 0 because outlined values flush first“$1”plain reference to the symbol row — already present, so it blocks nothing“$L2”lazy reference to the content
createFromReadableStream (or createFromFetch in the browser) keeps a table of chunks keyed by row id. Each row resolves its chunk; references to chunks that have not arrived yet create pending chunks that later rows fulfil. The root model is a thenable a component reads with use().
Fizz renders the model into HTML. Everything outside boundaries is the shell; a boundary whose content suspends gets its fallback in the shell and a task that finishes later. onShellReady fires once the shell — and the preamble — is complete; without a boundary above the hole, that is only after the hole is filled.
captured — first chunk, suspense scenario (line breaks added)
no boundaryone chunk at the end: shell ready only after Flight row 1boundaryshell at once; content chunk laterpreamble<html>/<head>/<body> must be settled before the shell flushes — observed on 19.3.0: a fragment root with a pending boundary held the shell until the boundary completed
When the boundary’s content arrives, Fizz emits it hidden plus a script call. $RC("B:0","S:0") locates the template, moves the hidden segment’s children into the boundary in place of the fallback, and flips the comment to $. This runs before hydration, on plain DOM.
captured — later chunk, suspense scenario (function bodies elided)
<divhiddenid="S:0"><article><h1>Understanding Streams</h1><p>A stream is a queue of Buffers…</p></article></div>
batchingreveals are batched ($RB, $RV) against the $RT timestamp instead of applied instantly — visible in the captured scripterrors$RX("B:0",digest) marks the boundary client-rendered insteadtwo boundariesS:1 was swapped before S:0 in the probe — completion order again
Nothing in the Flight rows changes: row 0 still carries "$L1". What changes is that no consumer has anywhere to put a fallback. Fizz cannot complete the shell, so the entire HTML response waits for row 1; on a navigation, the transition stays pending and the previous page stays on screen until the hole is filled.
siblings scenario, SSR siderow 0 at 81.7 ms (child-process clock), row 1 at 120.6 ms, onShellReady at 121.7 ms: one HTML writewhat React never doesinvent a placeholder for a component that has no boundary
In the App Router a loading.tsx file in a segment makes Next.js wrap that segment’s page in a Suspense boundary whose fallback is the file’s default export. An async page.tsx then streams exactly like the suspense scenario. No file, no boundary.
Next.js resolves the URL to nested segments and builds one element tree: root layout → nested layouts → page, with boundaries from loading.tsx and error.tsx inserted where those files exist. That tree is the model the Flight server renders; the tree also carries the router state the client needs.
root layoutrenders <html>/<body> synchronously — settles Fizz’s preamblepagemay be async; suspends like ArticlePagerow 0in Next.js it is a wrapper object with the tree inside, not the bare fragment of the probes
The Flight stream is teed: one copy feeds Fizz, the other is inlined into the HTML as <script>self.__next_f.push([1,"…"])</script> chunks interleaved with the markup. On the client the same rows are decoded to hydrate — the tree is not rendered a second time on the server.
A Link click fetches the destination with the RSC:1 header and applies the rows inside startTransition. The Flight client decodes them straight into elements. A "$L…" hole suspends where it is rendered: inside a new boundary that shows its fallback; without one, the transition stays pending and the old page remains until the row arrives.
boundary is new contentfallback appearsboundary already visibleold content stays (transitions do not hide shown content)no boundarythe navigation waits; useLinkStatus reports pending
A synchronous Server Component can read a promise with use(promise). On the server, a pending thenable makes use throw SuspenseException; renderModel parks a task exactly as for an async component, but the task’s model is the element, so the retry calls the component function again from the top. The tracked thenable list (thenableState) returns the fulfilled value at the same call index.
ArticleUse.tsx
1functionArticleUse(){
2constarticle=use(db.query("SELECT * FROM articles WHERE id = 42"));// runs twice!
5// probe: "ArticleUse() body starts (invocation #1)" … "(invocation #2)"
6// the second db.query() call was issued and its promise abandoned
L2 — re-invokedthe second call creates a second, discarded query
L2 — re-invokedthe second call creates a second, discarded query
awaitcomponent runs once; promise held by the lazy wrapperusecomponent re-runs; result read from thenableStaterulecreate the promise outside render (or cache it) when using use