@samitouri / QOS-React / commits / 994ebf9dce

[Fixture] flight-ssr-bench: drain immediates between iterations to fix false memory leak (#36968)

Claude found this while running the Flight/Fizz bench. --- While measuring memory in this fixture, the Flight server appeared to leak ~140KB per render: `heapUsed` grew steadily across hundreds of renders even with forced GC, and roughly 3x worse when a Flight client consumed the stream in the same process. Fizz-only runs were flat, so it looked like a real Flight leak. On a closer look, the benchmark loop seems to be lying. React schedules one `setImmediate` per request, but every render in this fixture completes entirely in promises and `nextTick` callbacks, so a tight benchmark loop never gives the event loop a chance to run immediates at all. They quietly pile up, and each pending callback keeps its already-finished request alive. Heap snapshots show that every "leaked" request was held by a pending immediate, and nothing else. Adding a single `await setImmediate` per iteration makes memory completely flat over 300 renders, with or without a same-process Flight client. A real server yields to the event loop on every request, so this can't happen outside a synthetic loop. The fix is to yield to the event loop between benchmark iterations, outside the timed window, so latency numbers are unaffected. Each variant now also reports how much heap it retains after the run settles. That number was meaningless before — the pile-up made variants with bigger per-request graphs look like they used more memory when they didn't. After the fix, a full `yarn bench:bare` run retains +0.3–0.6MB total per variant after 1000 iterations (JIT/warmup noise), instead of drifting by hundreds of MB. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

dan committed Jul 8, 2026 at 13:31 UTC 994ebf9dce1708045d9570f2fab7702be4763f97
2 files changed +62 -10
fixtures/flight-ssr-bench/README.md
+2
@@ -54,6 +54,8 @@ A dashboard with ~25 components (16 client components), rendering:
54
55 ## Output
56
57 +Each variant reports render latency stats, GC pauses, and (when run with `--expose-gc`, which the `yarn bench*` scripts do) the heap retained after the run settles.
58 +
59 The overhead tables show two comparisons:
60
61 1. **Flight overhead** -- Flight+Fizz vs Fizz-only (how much RSC adds)
fixtures/flight-ssr-bench/bench.js
+60 -10
@@ -93,12 +93,31 @@ function renderFlightFizzEdge(renderRSCEdge, AppComponent, itemCount) {
93
94 const canGC = typeof globalThis.gc === 'function';
95
96 +// Yield to the event loop's check phase between renders. React schedules a
97 +// setImmediate per request, but a fully in-process render completes entirely
98 +// in microtasks/nextTicks, so a tight benchmark loop never lets those
99 +// immediates run. They then pile up in Node's immediate queue, each one
100 +// retaining its (otherwise finished) request graph, which inflates any memory
101 +// measurement. A real server yields to the event loop on every request, so
102 +// draining between iterations is both correct and more representative.
103 +function tick() {
104 + return new Promise(resolve => setImmediate(resolve));
105 +}
106 +
107 +async function settledHeapUsed() {
108 + await tick();
109 + if (canGC) globalThis.gc();
110 + return process.memoryUsage().heapUsed;
111 +}
112 +
113 async function runBenchmark(name, fn, iterations, warmup) {
114 if (canGC) globalThis.gc();
115 + const heapBefore = await settledHeapUsed();
116
117 // Warmup
118 for (let i = 0; i < warmup; i++) {
119 await fn();
120 + await tick();
121 }
122
123 // Collect GC pauses during timed iterations.
@@ -112,14 +131,17 @@ async function runBenchmark(name, fn, iterations, warmup) {
131 });
132 gcObs.observe({entryTypes: ['gc']});
133
115 - // Timed iterations
134 + // Timed iterations. The tick between iterations is excluded from the
135 + // timed window.
136 const times = [];
137 for (let i = 0; i < iterations; i++) {
138 const start = performance.now();
139 await fn();
140 times.push(performance.now() - start);
141 + await tick();
142 }
143 gcObs.disconnect();
144 + const heapAfter = await settledHeapUsed();
145
146 // Trim top/bottom 5% to remove outliers
147 const sorted = [...times].sort((a, b) => a - b);
@@ -146,6 +168,8 @@ async function runBenchmark(name, fn, iterations, warmup) {
168 iterations,
169 gcCount,
170 gcTotalMs,
171 + heapBefore,
172 + heapAfter,
173 };
174 }
175
@@ -163,13 +187,28 @@ function printResult(result) {
187 result.gcTotalMs.toFixed(1),
188 (result.gcTotalMs / result.iterations).toFixed(2)
189 );
190 + printHeap(result);
191 +}
192 +
193 +function printHeap(result) {
194 + if (!canGC) return;
195 + const mb = b => (b / 1048576).toFixed(1);
196 + const delta = result.heapAfter - result.heapBefore;
197 + console.log(
198 + ' Heap: %s MB retained after run (%s%s MB vs before)',
199 + mb(result.heapAfter),
200 + delta >= 0 ? '+' : '',
201 + mb(delta)
202 + );
203 }
204
205 async function runConcurrent(name, fn, total, concurrency, warmup) {
206 if (canGC) globalThis.gc();
207 + const heapBefore = await settledHeapUsed();
208
209 for (let i = 0; i < warmup; i++) {
210 await fn();
211 + await tick();
212 }
213
214 let gcCount = 0;
@@ -192,21 +231,27 @@ async function runConcurrent(name, fn, total, concurrency, warmup) {
231 while (launched < total && launched - completed < concurrency) {
232 const idx = launched++;
233 const t0 = performance.now();
195 - fn().then(() => {
196 - latencies[idx] = performance.now() - t0;
197 - completed++;
198 - if (completed === total) {
199 - resolve();
200 - } else {
201 - launch();
202 - }
203 - });
234 + fn()
235 + .then(() => {
236 + latencies[idx] = performance.now() - t0;
237 + // Drain immediates before freeing the slot (see tick()).
238 + return tick();
239 + })
240 + .then(() => {
241 + completed++;
242 + if (completed === total) {
243 + resolve();
244 + } else {
245 + launch();
246 + }
247 + });
248 }
249 }
250 launch();
251 });
252 const elapsed = performance.now() - start;
253 gcObs.disconnect();
254 + const heapAfter = await settledHeapUsed();
255
256 const sorted = [...latencies].sort((a, b) => a - b);
257 const mean = sorted.reduce((s, t) => s + t, 0) / sorted.length;
@@ -221,6 +266,8 @@ async function runConcurrent(name, fn, total, concurrency, warmup) {
266 concurrency,
267 gcCount,
268 gcTotalMs,
269 + heapBefore,
270 + heapAfter,
271 };
272 }
273
@@ -235,6 +282,7 @@ function printConcurrentResult(result) {
282 result.gcTotalMs.toFixed(1),
283 (result.gcTotalMs / result.total).toFixed(2)
284 );
285 + printHeap(result);
286 }
287
288 // ---------------------------------------------------------------------------
@@ -307,6 +355,7 @@ async function profileRun(name, fn, warmup, iterations, outputPath) {
355 // Warmup (unprofiled)
356 for (let i = 0; i < warmup; i++) {
357 await fn();
358 + await tick();
359 }
360
361 // Collect GC pauses during the profiled run.
@@ -324,6 +373,7 @@ async function profileRun(name, fn, warmup, iterations, outputPath) {
373 const session = await startProfiler();
374 for (let i = 0; i < iterations; i++) {
375 await fn();
376 + await tick();
377 }
378 const profile = await stopProfiler(session, outputPath);
379 gcObs.disconnect();