[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: Hanzo Dev <[email protected]>
This commit is contained in:
dan
2026-07-08 13:31:23 +01:00
committed by GitHub
co-authored by Hanzo Dev
parent 9277edc43a
commit 2a784bedd2
2 changed files with 62 additions and 10 deletions
+2
View File
@@ -54,6 +54,8 @@ A dashboard with ~25 components (16 client components), rendering:
## Output
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.
The overhead tables show two comparisons:
1. **Flight overhead** -- Flight+Fizz vs Fizz-only (how much RSC adds)
+60 -10
View File
@@ -93,12 +93,31 @@ function renderFlightFizzEdge(renderRSCEdge, AppComponent, itemCount) {
const canGC = typeof globalThis.gc === 'function';
// Yield to the event loop's check phase between renders. React schedules a
// setImmediate per request, but a fully in-process render completes entirely
// in microtasks/nextTicks, so a tight benchmark loop never lets those
// immediates run. They then pile up in Node's immediate queue, each one
// retaining its (otherwise finished) request graph, which inflates any memory
// measurement. A real server yields to the event loop on every request, so
// draining between iterations is both correct and more representative.
function tick() {
return new Promise(resolve => setImmediate(resolve));
}
async function settledHeapUsed() {
await tick();
if (canGC) globalThis.gc();
return process.memoryUsage().heapUsed;
}
async function runBenchmark(name, fn, iterations, warmup) {
if (canGC) globalThis.gc();
const heapBefore = await settledHeapUsed();
// Warmup
for (let i = 0; i < warmup; i++) {
await fn();
await tick();
}
// Collect GC pauses during timed iterations.
@@ -112,14 +131,17 @@ async function runBenchmark(name, fn, iterations, warmup) {
});
gcObs.observe({entryTypes: ['gc']});
// Timed iterations
// Timed iterations. The tick between iterations is excluded from the
// timed window.
const times = [];
for (let i = 0; i < iterations; i++) {
const start = performance.now();
await fn();
times.push(performance.now() - start);
await tick();
}
gcObs.disconnect();
const heapAfter = await settledHeapUsed();
// Trim top/bottom 5% to remove outliers
const sorted = [...times].sort((a, b) => a - b);
@@ -146,6 +168,8 @@ async function runBenchmark(name, fn, iterations, warmup) {
iterations,
gcCount,
gcTotalMs,
heapBefore,
heapAfter,
};
}
@@ -163,13 +187,28 @@ function printResult(result) {
result.gcTotalMs.toFixed(1),
(result.gcTotalMs / result.iterations).toFixed(2)
);
printHeap(result);
}
function printHeap(result) {
if (!canGC) return;
const mb = b => (b / 1048576).toFixed(1);
const delta = result.heapAfter - result.heapBefore;
console.log(
' Heap: %s MB retained after run (%s%s MB vs before)',
mb(result.heapAfter),
delta >= 0 ? '+' : '',
mb(delta)
);
}
async function runConcurrent(name, fn, total, concurrency, warmup) {
if (canGC) globalThis.gc();
const heapBefore = await settledHeapUsed();
for (let i = 0; i < warmup; i++) {
await fn();
await tick();
}
let gcCount = 0;
@@ -192,21 +231,27 @@ async function runConcurrent(name, fn, total, concurrency, warmup) {
while (launched < total && launched - completed < concurrency) {
const idx = launched++;
const t0 = performance.now();
fn().then(() => {
latencies[idx] = performance.now() - t0;
completed++;
if (completed === total) {
resolve();
} else {
launch();
}
});
fn()
.then(() => {
latencies[idx] = performance.now() - t0;
// Drain immediates before freeing the slot (see tick()).
return tick();
})
.then(() => {
completed++;
if (completed === total) {
resolve();
} else {
launch();
}
});
}
}
launch();
});
const elapsed = performance.now() - start;
gcObs.disconnect();
const heapAfter = await settledHeapUsed();
const sorted = [...latencies].sort((a, b) => a - b);
const mean = sorted.reduce((s, t) => s + t, 0) / sorted.length;
@@ -221,6 +266,8 @@ async function runConcurrent(name, fn, total, concurrency, warmup) {
concurrency,
gcCount,
gcTotalMs,
heapBefore,
heapAfter,
};
}
@@ -235,6 +282,7 @@ function printConcurrentResult(result) {
result.gcTotalMs.toFixed(1),
(result.gcTotalMs / result.total).toFixed(2)
);
printHeap(result);
}
// ---------------------------------------------------------------------------
@@ -307,6 +355,7 @@ async function profileRun(name, fn, warmup, iterations, outputPath) {
// Warmup (unprofiled)
for (let i = 0; i < warmup; i++) {
await fn();
await tick();
}
// Collect GC pauses during the profiled run.
@@ -324,6 +373,7 @@ async function profileRun(name, fn, warmup, iterations, outputPath) {
const session = await startProfiler();
for (let i = 0; i < iterations; i++) {
await fn();
await tick();
}
const profile = await stopProfiler(session, outputPath);
gcObs.disconnect();