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

@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

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

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!

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.

1 participant