Skip to content

[Fizz] Reduce chunk push overhead in HTML serialization#36139

Open
switz wants to merge 1 commit intofacebook:mainfrom
switz:fizz-perf-pr
Open

[Fizz] Reduce chunk push overhead in HTML serialization#36139
switz wants to merge 1 commit intofacebook:mainfrom
switz:fizz-perf-pr

Conversation

@switz
Copy link
Copy Markdown

@switz switz commented Mar 25, 2026

I spent some time yesterday digging into RSC performance with Claude. I think there are two much bigger issues to tackle than this (one being web vs. node streams and the other being an architectural bottleneck), but this PR did provide some nice wins in a variety of benchmarks.

I have a separate improvement to Flight on another branch, but I'm a tad more skeptical of that PR. Plus, its gains were more modest.

Benchmark (Node v24, Apple M1 Max, 1000 iterations, 100 warmup):

Tree Before (ops/s) After (ops/s) Δ
Small (10 el) 37,959 48,257 +27.1%
Medium (300 el, 3 Suspense, inline styles + data attrs) 2,309 3,518 +52.4%
Medium (300 el, no Suspense, inline styles + data attrs) 2,404 3,725 +54.9%
Tailwind (300 el, 3 Suspense, className-only) 4,275 5,965 +39.5%
Large (2500+ el, 12 Suspense) 429 626 +45.9%

This is just one small part of the pipeline, so it's not representative of major e2e gains.

Below are Claude's notes–feel free to turn down this PR if you find its efficacy lacking. The byteLengthOfChunk change I'm a touch more nervous about, but if it's only used for heuristics this feels okay.


Summary

Batches attribute and style serialization into single string pushes instead of 3-5 separate
target.push() calls per attribute. This reduces array operations, chunk count in segment buffers, and
downstream Buffer.byteLength and writeChunk/encodeInto call volume.

Motivation

Profiling renderToPipeableStream with V8's --prof showed that pushAttribute and
pushStartGenericElement were the top JS hot paths, with the dominant cost being the sheer number of
target.push() calls per element. A <div> with 6 attributes and a 6-property style object was doing
~54 array pushes for attributes alone. Each push adds an item to the segment's chunk array, which then
has to be iterated by finishedSegment for byte sizing and by flushSubtree for encoding.

Changes

Attribute push batching (ReactFizzConfigDOM.js):

  • pushStringAttribute: 5 pushes (attributeSeparator, name, attributeAssign, escapedValue,
    attributeEnd) → 1 push with ' ' + name + '="' + escapeTextForBrowser(value) + '"'
  • pushBooleanAttribute: 3 pushes → 1 push
  • pushStyleAttribute: 4N+1 pushes (N = number of style properties) → 1 push. Builds the entire
    style="..." string before pushing.
  • pushAttribute default case (data-, aria-, etc.), URL attributes, enumerated attributes, numeric
    attributes, overloaded booleans: all consolidated from 3-5 pushes to 1.
  • processStyleName: returns plain string instead of PrecomputedChunk to enable string concatenation
    in style building.

Text child merging (ReactFizzConfigDOM.js):

  • In pushStartGenericElement and pushStartAnchor: when children is a string and there's no
    dangerouslySetInnerHTML, merge the > with the escaped text into a single string push.

Byte length fast path (ReactServerStreamConfigNode.js):

  • byteLengthOfChunk: use string.length instead of Buffer.byteLength(chunk, 'utf8'). For ASCII
    content (99%+ of HTML attribute/tag output), .length === byte length. The slight underestimate for
    multi-byte characters is acceptable since byteSize is only used for heuristic decisions (outlining
    threshold at 500 bytes, progressive chunk sizing).

Results

Benchmarked with renderToPipeableStream on Node v24.14.0, Apple M1 Max. Renders piped to a
byte-counting Writable (no I/O). 100 warmup, 1000 measured iterations, <1% coefficient of variation.

Output HTML is byte-identical before and after.

How did you test this change?

All existing Fizz and Server integration tests pass (4,088 tests across 150 suites). Verified HTML
output identity for elements with className, id, style objects, data-* attributes, escaped text content,
void elements, and SVG namespace attributes.

Batch attribute and style serialization into single string pushes
instead of 3-5 separate push calls per attribute. This reduces array
operations, chunk count in segment buffers, and downstream
Buffer.byteLength and writeChunk/encodeInto call volume.

Changes:
- pushStringAttribute: 5 pushes → 1 concatenated string push
- pushBooleanAttribute: 3 pushes → 1 push
- pushStyleAttribute: 4N+1 pushes → 1 push (build full style string)
- pushAttribute default/URL/enumerated/numeric cases: 5 pushes → 1 push
- processStyleName: returns plain string instead of PrecomputedChunk
- pushStartGenericElement/pushStartAnchor: merge close tag with text
  children into single push when no dangerouslySetInnerHTML
- byteLengthOfChunk: use string.length instead of Buffer.byteLength
  for heuristic byte sizing (outlining threshold, progressive chunks).
  For ASCII (99%+ of HTML output), length === byte length.

Benchmark (Node v24, Apple M1 Max, 1000 iterations, 100 warmup):

| Tree                        | Before    | After     | Δ        |
|-----------------------------|-----------|-----------|----------|
| Small (10 el)               | 37,959    | 48,257    | +27.1%   |
| Medium (300 el, Suspense)   | 2,309     | 3,518     | +52.4%   |
| Medium (300 el, no Suspense)| 2,404     | 3,725     | +54.9%   |
| Large (2500+ el, Suspense)  | 429       | 626       | +45.9%   |
@meta-cla
Copy link
Copy Markdown

meta-cla bot commented Mar 25, 2026

Hi @switz!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@meta-cla
Copy link
Copy Markdown

meta-cla bot commented Mar 25, 2026

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@react-sizebot
Copy link
Copy Markdown

Comparing: 3cb2c42...937ef93

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.84 kB 6.84 kB +0.05% 1.88 kB 1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 612.88 kB 612.88 kB = 108.30 kB 108.30 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.84 kB 6.84 kB = 1.88 kB 1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 678.81 kB 678.81 kB = 119.26 kB 119.26 kB
facebook-www/ReactDOM-prod.classic.js = 698.20 kB 698.20 kB = 122.65 kB 122.65 kB
facebook-www/ReactDOM-prod.modern.js = 688.52 kB 688.52 kB = 121.03 kB 121.03 kB
facebook-www/ESLintPluginReactHooks-dev.classic.js Deleted 2,042.93 kB 0.00 kB Deleted 298.52 kB 0.00 kB
facebook-www/ESLintPluginReactHooks-dev.modern.js Deleted 2,042.93 kB 0.00 kB Deleted 298.52 kB 0.00 kB
facebook-www/ESLintPluginReactHooks-prod.classic.js Deleted 2,038.49 kB 0.00 kB Deleted 297.55 kB 0.00 kB
facebook-www/ESLintPluginReactHooks-prod.modern.js Deleted 2,038.49 kB 0.00 kB Deleted 297.55 kB 0.00 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/react-dom/cjs/react-dom-server.bun.development.js = 402.27 kB 401.38 kB = 75.97 kB 75.90 kB
oss-experimental/react-dom/cjs/react-dom-server.edge.development.js = 455.35 kB 454.33 kB = 80.66 kB 80.63 kB
oss-experimental/react-dom/cjs/react-dom-server.browser.development.js = 454.35 kB 453.32 kB = 80.43 kB 80.39 kB
oss-stable/react-dom/cjs/react-dom-server.edge.development.js = 438.84 kB 437.82 kB = 78.35 kB 78.33 kB
oss-stable-semver/react-dom/cjs/react-dom-server.edge.development.js = 438.77 kB 437.74 kB = 78.30 kB 78.28 kB
oss-stable/react-dom/cjs/react-dom-server.browser.development.js = 438.06 kB 437.04 kB = 78.16 kB 78.14 kB
oss-stable-semver/react-dom/cjs/react-dom-server.browser.development.js = 437.99 kB 436.96 kB = 78.11 kB 78.09 kB
oss-experimental/react-dom/cjs/react-dom-server.node.development.js = 461.11 kB 459.99 kB = 80.42 kB 80.37 kB
oss-experimental/react-dom/cjs/react-dom-server-legacy.browser.development.js = 423.78 kB 422.74 kB = 76.16 kB 76.10 kB
oss-experimental/react-dom/cjs/react-dom-server-legacy.node.development.js = 423.78 kB 422.74 kB = 76.16 kB 76.10 kB
oss-stable/react-dom/cjs/react-dom-server.bun.development.js = 388.11 kB 387.15 kB = 73.65 kB 73.52 kB
oss-stable-semver/react-dom/cjs/react-dom-server.bun.development.js = 388.04 kB 387.08 kB = 73.63 kB 73.49 kB
oss-stable/react-dom/cjs/react-dom-server.node.development.js = 445.10 kB 443.98 kB = 78.16 kB 78.09 kB
oss-stable-semver/react-dom/cjs/react-dom-server.node.development.js = 445.03 kB 443.90 kB = 78.11 kB 78.04 kB
oss-experimental/react-markup/cjs/react-markup.development.js = 395.51 kB 394.47 kB = 71.83 kB 71.76 kB
facebook-www/ReactDOMServer-dev.classic.js = 422.45 kB 421.33 kB = 75.84 kB 75.79 kB
facebook-www/ReactDOMServer-dev.modern.js = 419.01 kB 417.89 kB = 75.24 kB 75.20 kB
facebook-www/ReactDOMServerStreaming-dev.modern.js = 417.69 kB 416.57 kB = 74.82 kB 74.76 kB
oss-stable/react-dom/cjs/react-dom-server-legacy.browser.development.js = 408.49 kB 407.37 kB = 73.95 kB 73.90 kB
oss-stable/react-dom/cjs/react-dom-server-legacy.node.development.js = 408.49 kB 407.37 kB = 73.95 kB 73.90 kB
oss-stable-semver/react-dom/cjs/react-dom-server-legacy.browser.development.js = 408.47 kB 407.35 kB = 73.92 kB 73.87 kB
oss-stable-semver/react-dom/cjs/react-dom-server-legacy.node.development.js = 408.47 kB 407.35 kB = 73.92 kB 73.87 kB
oss-experimental/react-dom/cjs/react-dom-server.node.production.js = 302.84 kB 301.90 kB = 53.23 kB 53.15 kB
oss-stable/react-dom/cjs/react-dom-server.node.production.js = 291.17 kB 290.23 kB = 51.38 kB 51.30 kB
oss-stable-semver/react-dom/cjs/react-dom-server.node.production.js = 291.09 kB 290.15 kB = 51.35 kB 51.27 kB
oss-experimental/react-dom/cjs/react-dom-server.edge.production.js = 295.12 kB 294.09 kB = 53.37 kB 53.29 kB
oss-experimental/react-dom/cjs/react-dom-server.browser.production.js = 288.82 kB 287.79 kB = 51.04 kB 50.97 kB
oss-stable/react-dom/cjs/react-dom-server.edge.production.js = 283.11 kB 282.07 kB = 51.52 kB 51.46 kB
oss-stable-semver/react-dom/cjs/react-dom-server.edge.production.js = 283.03 kB 282.00 kB = 51.49 kB 51.43 kB
oss-stable/react-dom/cjs/react-dom-server.browser.production.js = 277.48 kB 276.45 kB = 49.42 kB 49.35 kB
oss-stable-semver/react-dom/cjs/react-dom-server.browser.production.js = 277.41 kB 276.38 kB = 49.40 kB 49.33 kB
facebook-www/ESLintPluginReactHooks-dev.classic.js Deleted 2,042.93 kB 0.00 kB Deleted 298.52 kB 0.00 kB
facebook-www/ESLintPluginReactHooks-dev.modern.js Deleted 2,042.93 kB 0.00 kB Deleted 298.52 kB 0.00 kB
facebook-www/ESLintPluginReactHooks-prod.classic.js Deleted 2,038.49 kB 0.00 kB Deleted 297.55 kB 0.00 kB
facebook-www/ESLintPluginReactHooks-prod.modern.js Deleted 2,038.49 kB 0.00 kB Deleted 297.55 kB 0.00 kB

Generated by 🚫 dangerJS against 937ef93

Copy link
Copy Markdown
Collaborator

@gnoff gnoff left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you open a change with just the attribute improvements I think we can land that. The innerHTML stuff needs a little more work and not sure if it's worth it. The byteLength i'd just leave out

Comment on lines +225 to +229
? chunk.length // Fast path: .length === byte length for ASCII (99%+ of HTML output).
: // For multi-byte chars this slightly underestimates, which is fine
// because byteSize is only used for heuristic decisions (outlining
// threshold and progressive chunk sizing).
chunk.byteLength;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change should have broken something in ReactFlightServer. Suggests that we have some untested serialization path with large string serialization.

Even if the rationale was true though we can't land this change because the layering implies you get back an accurate byteLength. We'd have to rename to getApproximateByteLength etc...

Unless this is like the entirety of the perf win it doesn't seem worth the effort to rewrite the parts that use this length for approximating heuristics so let's just drop it.

If you want to see what would be broken by this check out emitTextChunk in ReactFlightServer

target.push(stringToChunk(encodeHTMLTextNode(children)));
if (innerHTML == null && typeof children === 'string') {
// Fast path: close tag and emit text child in a single push.
target.push(stringToChunk('>' + encodeHTMLTextNode(children)));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason it was written the way it was before is we try to avoid branching on hot paths as much as possible. In this case you are adding an extra if check to every element that has non string children because we check the innerHTML twice instead of once.

There is probably a more dramatic refactor that could avoid the extra conditional and still allow for the avoidance of the extra chunk.

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.

3 participants