- JavaScript 76.3%
- Shell 13.6%
- TypeScript 10.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| app | ||
| bench | ||
| patches | ||
| results | ||
| .gitignore | ||
| README.md | ||
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.patchusesreact-dom/server.nodeandreact-dom/static.nodeinstead 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.patchmakes 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.mjschecks 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.nodecuts 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.stringifyand 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-rsc0.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 fromapp/[NODE_BIN=...] bench/run.sh <app dir> <name>runs the server under a CPU profile for 5 s warm-up plus 30 s load intoresults/<name>/node bench/summary.mjs results/*prints the tablenode bench/analyze.mjs results/<name>/profile.cpuprofile.gzshows CPU by area, top functions and what fills long blocksnode bench/drill.mjs results/<name>/profile.cpuprofile.gzshows who callsTextEncoder.encode, plus the top Node internals and bundle functionsnode bench/verify.mjschecks that the yield patch gives the same output and measures the longest block (needsapp-origandapp-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.