No description
  • JavaScript 76.3%
  • Shell 13.6%
  • TypeScript 10.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
foosinn b5b852cdc2 Add Node 26 results and the plugin-rsc test to the README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:10:42 +02:00
app vinext SSR performance on Node: patches and benchmark 2026-10-23 23:46:03 +02:00
bench Let the benchmark run the server on another Node binary 2026-10-24 00:10:42 +02:00
patches vinext SSR performance on Node: patches and benchmark 2026-10-23 23:46:03 +02:00
results Add Node 26 benchmark and name results by Node version 2026-10-24 00:10:42 +02:00
.gitignore vinext SSR performance on Node: patches and benchmark 2026-10-23 23:46:03 +02:00
README.md Add Node 26 results and the plugin-rsc test to the README 2026-10-24 00:10:42 +02:00

vinext SSR performance on Node

Two small patches for vinext 1.3.0 that make large App Router pages cheaper to render on Node and stop them from blocking the event loop for so long.

The problem

A production app on vinext 1.3.0 on Node has one page of about 1.2 MB per response (HTML plus the embedded RSC payload). After this page went live, event loop delay went up from ~11 ms to ~17 ms. Outgoing HTTP calls of the app, normally under 1 ms, sometimes took up to 20 ms. Load is low: 2-3 page requests per second.

The app source can't be shared, so app/ is a test app that builds the same kind of page: / renders 3800 client components into a ~1.2 MB response. /api/ping calls a local upstream, so we can see how long other work has to wait while a page renders.

Findings

1. vinext uses react-dom/server.edge on Node. dist/server/app-ssr-entry.js imports renderToReadableStream from react-dom/server.edge (and prerender from react-dom/static.edge) on every target. The edge build turns every attribute and text piece into bytes with TextEncoder.encode on its own (stringToChunk). That is ~12% of CPU, plus extra GC. react-dom/server.node has the same renderToReadableStream API but writes strings into a 4 KB buffer. The HTML is the same byte for byte.

2. vinext merges the HTML and RSC streams in one synchronous block. createTickBufferedTransform and createRscEmbedTransform in dist/server/app-ssr-stream.js handle all pending chunks in one go: join, regexes, JSON.stringify and escaping per 64 KB batch, encode. For a 1.2 MB page that is a block of ~10-15 ms. It is not much CPU, but nothing else can run during that time.

3. What is left (with both patches): Node internals (web streams, AsyncLocalStorage) ~21%, GC ~19%, RSC bundle (server components and RSC serialize) ~18%, SSR bundle (client components and RSC parse) ~15%, React HTML render ~14%. The longest remaining blocks are React's own render work.

The patches

Both patches are for vinext 1.3.0 dist/. They change different files and can be applied in any order.

  • patches/vinext-server-node.patch uses react-dom/server.node and react-dom/static.node instead of the edge builds. Node only: Cloudflare Workers need the edge build. An upstream fix would have to pick the build per target.
  • patches/vinext-yield.patch makes the RSC embed step work in 64 KB slices and yields to the event loop (setImmediate) between them. The output is the same; bench/verify.mjs checks this:
    longest block before after
    all chunks at once 14.2 ms 1.6 ms
    chunks arrive over time 14.8 ms 4.1 ms

To try them in an app:

patch -p1 -d node_modules/vinext < vinext-server-node.patch
patch -p1 -d node_modules/vinext < vinext-yield.patch
npx vite build

Use patch-package or pnpm patch to keep them after npm install.

Results

Test app, 3 page requests per second, plus a ping to /api/ping every 20 ms. Server pinned to one core of an i7-1280P, React 19.3.1, NODE_ENV=production. Three rounds per variant, so each cell shows the range.

Node 22.23.3

Variant CPU per request* Page p50 / p99 Event loop max Ping p99 / max
vinext 1.3.0 86-96 ms 37-40 / 115-123 ms 113-125 ms 101-116 / 169-193 ms
+ yield 87-93 ms 38-40 / 102-109 ms 86-104 ms 75-92 / 108-123 ms
+ server.node 70-73 ms 29-30 / 78-82 ms 79-107 ms 73-80 / 115-126 ms
+ both 72-73 ms 30 / 80-87 ms 62-75 ms 52-54 / 81-85 ms

Node 26.12.0 (same builds, only the server runs on Node 26)

Variant CPU per request* Page p50 / p99 Event loop max Ping p99 / max
vinext 1.3.0 81-83 ms 35-36 / 103-110 ms 107-111 ms 83-96 / 143-230 ms
+ yield 81-83 ms 35-36 / 96-111 ms 85-117 ms 63-64 / 90-126 ms
+ server.node 58-61 ms 24-25 / 61-73 ms 78-84 ms 58-60 / 116-125 ms
+ both 62-65 ms 26-27 / 76-83 ms 49-53 ms 46-49 / 68-82 ms

* Busy CPU during the load phase divided by page requests. It includes the ping requests and profiler overhead, so compare rows only with each other.

  • server.node cuts CPU per request by ~20% and page latency by ~25%.
  • The yield patch saves no CPU, but breaks up long blocks, so other requests wait less.
  • Together, the worst ping goes from ~190 ms down to ~83 ms on Node 22, and down to ~75 ms on Node 26.
  • Node 26 alone saves another ~16% CPU (both patches, Node 22 runs in the same session: 75-77 ms, Node 26: 62-65 ms). AsyncLocalStorage no longer uses async_hooks, web streams cost less than half, and GC drops by ~30%. The patches help on Node 26 just as much.
  • All variants return the same HTML (apart from the build ID), and no request failed.
  • About 10% of pings take over 10 ms in every variant. The patches make slow calls shorter, not rarer.

The test app is heavier per byte than the real page, so absolute numbers are higher than in production. The raw data is in results/n<node>-<variant>-<round>/, including CPU profiles you can open in Chrome DevTools after gunzip.

Things that did not help

  • A native escaper or JSON encoder: JSON.stringify and V8 regexes are already native code. A napi addon would lose the gain to copying across the boundary.
  • --max-semi-space-size=64: no clear effect on GC.
  • The same edge → node swap for the RSC server in @vitejs/plugin-rsc 0.5.38 (react-server-dom/server.edge): same output, but no change in CPU or latency. Its rows are mostly large strings, so the 4 KB buffer saves little.
  • Several Node processes per core: slow calls get rarer, not shorter. Risky with Kubernetes CPU limits (CFS throttling).

Run the benchmark

Needs Linux with at least 11 cores: the scripts use taskset to put the server on core 2, load on 4-7, the ping probe on 8 and the upstream on 10.

bench/all.sh

This builds four app variants (app-orig, app-yield, app-node, app-both), runs three rounds of each into results/, and prints a summary. One round takes about 45 s per variant. Set NODE_BIN to run the server on another Node, for example NODE_BIN=$(which node26) bench/all.sh. The scripts and builds keep using node from PATH.

Single steps:

  • bench/build.sh <app dir> [patch files...] builds one variant from app/
  • [NODE_BIN=...] bench/run.sh <app dir> <name> runs the server under a CPU profile for 5 s warm-up plus 30 s load into results/<name>/
  • node bench/summary.mjs results/* prints the table
  • node bench/analyze.mjs results/<name>/profile.cpuprofile.gz shows CPU by area, top functions and what fills long blocks
  • node bench/drill.mjs results/<name>/profile.cpuprofile.gz shows who calls TextEncoder.encode, plus the top Node internals and bundle functions
  • node bench/verify.mjs checks that the yield patch gives the same output and measures the longest block (needs app-orig and app-yield)

Stop servers by PID, not with pkill -f "vinext start": that also matches the shell that runs it.

Open points

  • Split the final HTML step in the yield patch too (~2 ms left). Careful not to split <link>, </head> or </body></html>.
  • Report findings 1 and 2 upstream.