Skip to content

[Flight] Pack deferred children into continuation rows#36053

Open
mhart wants to merge 1 commit intofacebook:mainfrom
mhart:mhart/add-array-continuations
Open

[Flight] Pack deferred children into continuation rows#36053
mhart wants to merge 1 commit intofacebook:mainfrom
mhart:mhart/add-array-continuations

Conversation

@mhart
Copy link

@mhart mhart commented Mar 17, 2026

Summary

This changes Flight's large-array splitting behavior so rendered children arrays can be packed into continuation rows instead of deferring every remaining child one-by-one once MAX_ROW_SIZE is exceeded.

In pages with many children (eg, paragraphs, tables) it leads to a 1.45x performance improvement in req/sec on RSC serialization/deserialization in local benchmarks as well as deployed Next.js code on Vercel (160ms -> 110ms P95). This is due to the per-row overhead in RSC and the reduced number of rows (3674 rows -> 240 rows).

Screenshot 2026-03-16 at 4 52 33 pm

Before this change, if a rendered children array crossed the row-size threshold, Flight would often emit one lazy row per remaining child. That creates many tiny rows and adds a lot of per-row overhead. With this change, MAX_ROW_SIZE is still respected, but Flight can hand the remaining tail of the children array to a continuation task, so later rows can still pack multiple children together when they fit.

To support this we move the toJSON handling out of the JSON.stringify replacer path and into an explicit recursive resolution step that uses v8's optimized single-arg JSON.stringify call. This allows us to control resolution, including splitting large rendered children arrays before stringifying.

Fixes #35125.

Why this is safe

This is intended to be a serialization strategy change, not a semantic change.

  • It does not introduce a new wire format. The server still emits normal model rows plus existing lazy row references ($L...).
  • It only changes how large rendered children arrays are chunked after they cross the size threshold.
  • It keeps plain data arrays on the existing path, so tuple-like props, map entries, and similar arrays are not treated as renderable children.
  • On the client, these continuation rows are resolved through the existing lazy/array model path, and the final rendered output remains the same.
  • Shared references and deduped values continue to round-trip correctly across packed rows.

In other words, this should only reduce unnecessary row fragmentation for large children lists while preserving the same decoded model.

How did you test this change?

Added coverage for:

  • packing trailing children into continuation rows,
  • preserving reused children-array references,
  • preserving deduped shared props across packed child rows,
  • preserving promise/map/client prop identity across packed child rows,
  • resolving shared children arrays referenced from continuation rows,
  • ensuring plain prop arrays are not treated as renderable children,
  • preserving the existing DEV warning behavior for top-level toJSON values.

Testing

  • volta run yarn test --silent --no-watchman ReactFlightDOMEdge-test
  • volta run yarn test --silent --no-watchman ReactFlight-test

Resolve large rendered children arrays into continuation tasks instead of deferring each remaining child one-by-one, while preserving toJSON warnings and deduped references across packed rows. Fixes facebook#35125.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Performance regression due to deferTask not batching

1 participant