The first benchmark looked convincing: when creating a large number of closures, a loop using let was sometimes 6–8× slower than one using var.
That makes for an easy headline:
letis much slower thanvar.
The problem is that the two programs do not have the same semantics.
for (var i = 0; i < N; i++) {
callbacks.push(() => i);
}
All callbacks share one binding. After the loop, every function returns N.
for (let i = 0; i < N; i++) {
callbacks.push(() => i);
}
Each iteration has its own binding. The callbacks return values from 0 through N - 1.
The first program preserves one shared mutable value. The second preserves N independent values. The useful question is therefore not “how expensive is the let keyword?” but:
What memory does V8 allocate when every escaping closure genuinely needs its own captured value, and can that memory predict the GC timing step before it is measured?
Separating syntax from semantics
The experiment used six controls:
| Case | State captured by the closure | Result |
|---|---|---|
capturedVar |
one shared var i
|
[N, N, …, N] |
capturedLet |
a separate let i per iteration |
[0, 1, …, N-1] |
factoryVar |
a factory parameter | [0, 1, …, N-1] |
copiedVar |
const value = i inside a var loop |
[0, 1, …, N-1] |
noCaptureVar |
the counter is not captured | identical callbacks |
noCaptureLet |
the counter is not captured | identical callbacks |
capturedLet, factoryVar, and copiedVar have the same required semantics despite different source shapes. capturedVar is intentionally not equivalent.
This distinguishes two explanations:
- If the keyword is intrinsically expensive, a difference should remain without capture.
- If independent escaping state is expensive, the three correct implementations should retain similar heap topology.
Experimental design and provenance
The measurements were made on August 3–4, 2026 with Node.js 25.2.0, V8 14.1.146.11-node.13, and Windows x64.
The original object measurements are in results/raw-results.json. The quantitative GC work is split into explicit training and holdout artifacts:
-
results/threshold-calibration.jsonandresults/threshold-analysis.json: five training semi-space settings and the regression; -
results/growth-diagnostic.json: max-only young-generation telemetry; -
results/prediction-plan.json/results/prediction-result.json: the first frozen holdout, including its failed prediction; -
results/prediction-plan-2.json/results/prediction-result-2.json: the revised, still out-of-sample holdout; -
results/barrier-topology.json,results/barrier-constant.json, andresults/barrier-constant-analysis.json: the SMI/reference control; -
results/equivalence.jsonandresults/equivalence-analysis.json: the application-like TOST.
Every threshold decision used five one-trial fresh processes. A grid point counted as crossing when at least three of five runs contained an attributed minor GC. A majority-directed binary search located a 2,000-closure bracket; the reported threshold is its midpoint. Holdout predictions were serialized before the corresponding result file existed, and the runner refuses to overwrite a result.
A configured maximum is not the current semi-space
--max-semi-space-size is a maximum, not a request to begin at that size. The Node CLI documentation says exactly that. v8.getHeapStatistics().total_available_size is also the wrong diagnostic: here it was about 4.3 GB because it describes the whole heap.
The experiment instead recorded all fields returned by v8.getHeapSpaceStatistics() for new_space and new_large_object_space before and after every trial. Node also warns that the availability and interpretation of these spaces can change with V8 versions, so no single field is silently renamed “the actual semi-space.”
The max-only diagnostic makes the growth visible. These are medians from three fresh capturedLet(250000) processes:
| Configured maximum |
new_space.space_size before |
after | Minor GCs |
|---|---|---|---|
| 2 MiB | 3.75 MiB | 4.00 MiB | 20 |
| 4 MiB | 7.00 MiB | 8.00 MiB | 10 |
| 8 MiB | 6.75 MiB | 9.50 MiB | 5 |
| 16 MiB | 5.75 MiB | 23.25 MiB | 3 |
| 32 MiB | 6.75 MiB | 23.00 MiB | 3 |
A “32 MiB semi-space” did not begin with 32 MiB committed, and two processes with different caps could begin in very similar states.
For the threshold regression, both --min-semi-space-size=S and --max-semi-space-size=S were set. V8 still committed pages lazily, so telemetry was retained, but this removed the most obvious max-only ambiguity. The regression and holdouts apply to this exact protocol; they are not a formula for an arbitrary already-running Node process.
Thresholds scale linearly with the configured semi-space
The measured first-GC brackets were:
S |
capturedLet threshold |
capturedVar threshold |
|---|---|---|
| 2 MiB | 41,000 | 101,000 |
| 4 MiB | 55,000 | 135,000 |
| 8 MiB | 95,000 | 189,000 |
| 16 MiB | 177,000 | 317,000 |
| 32 MiB | 335,000 | 579,000 |
Each midpoint has a grid half-width of 1,000 closures. Ordinary least squares with an intercept gives:
capturedLet:
N_threshold = 17,750 + 9,907.26 × S_MiB
slope SE = 111.20 closures/MiB
slope 95% CI = [9,553.36, 10,261.15]
R² = 0.99962
capturedVar:
N_threshold = 66,833 + 15,916.67 × S_MiB
slope SE = 212.55 closures/MiB
slope 95% CI = [15,240.25, 16,593.09]
R² = 0.99947
With S in bytes, the reciprocal slope estimates effective nursery bytes per iteration:
capturedLet: 105.84 B/iteration (95% CI 102.19–109.76)
capturedVar: 65.88 B/iteration (95% CI 63.19–68.80)
difference: 39.96 B/iteration (95% CI 35.26–44.66)
The confidence intervals use five fitted midpoints (df = 3) and treat them as observed responses; they do not add the ±1,000 grid uncertainty. The difference interval uses a delta-method calculation that treats the two slope estimates as independent. R² is strong evidence of linear scaling in this controlled protocol, not a universal law of V8 heaps.
An independent heap method predicts the same extra bytes
The threshold fit above did not use heap-snapshot sizes. A GC-only pilot selected broad search brackets, but only fresh threshold runs entered the fit.
Now compare its 39.96 B estimate with the heap graph. After forced full GC, 10,000 live closures produced:
| Case | Closures | Unique direct contexts | Closure self_size
|
Context self_size
|
heapUsed increase/item |
|---|---|---|---|---|---|
capturedVar |
10,000 | 1 | 64 B | 40 B | 72.03 B |
capturedLet |
10,000 | 10,000 | 64 B | 40 B | 112.02 B |
factoryVar |
10,000 | 10,000 | 64 B | 40 B | 112.02 B |
copiedVar |
10,000 | 10,000 | 64 B | 40 B | 111.95 B |
noCaptureVar |
10,000 | 1 | 64 B | 48 B | 72.01 B |
noCaptureLet |
10,000 | 1 | 64 B | 48 B | 72.02 B |
capturedLet therefore retains one additional 40-byte system / Context per item. The process-level delta independently agrees:
112.02152 B - 72.02624 B = 39.99528 B per callback
The threshold-only estimate was 39.96 B, just 0.04 B (0.1%) below the snapshot result, and 40 B lies inside its 95% confidence interval.
The estimands are not identical: one is effective traffic at a nursery threshold and the other is retained self_size after full GC. Their numerical agreement supports a specific explanation: the main differential allocation in this code shape is the one additional context.
The sampling allocation profile is directionally consistent but less exact. Its main-stack difference was 44.83 B/item, about 12% above 40 B. That is acceptable for a sampled profile; it should not be presented as an exact object-size measurement.
This claim remains version- and shape-specific:
In V8 14.1, for this escaping-capture pattern, every per-item value was represented by a distinct 40-byte context.
It is not a JavaScript language guarantee.
A predictive wall-time model
The threshold equation alone predicts only whether a step occurs. To predict wall time, the training runs fitted three coefficients after GC attribution:
M_let(N) = 0.0472 + 0.0000423015 × N ms (R² = 0.854)
M_var(N) = 2.4996 + 0.0000208576 × N ms (R² = 0.834)
G_let,2(N) = -2.6180 + 0.000116553 × N ms (R² = 0.760)
M was fitted only from fresh runs with no observed GC. G_let,2 was fitted from runs with exactly two attributed minor collections. The snapshot-constrained threshold model was:
N̂_let(S) = 15,577 + S_bytes / (64 B + 40 B)
N̂_var(S) = 61,038 + S_bytes / 64 B
The intercepts were estimated from the five training thresholds; the 64 B closure and additional 40 B context came from the independent snapshot.
The first holdout falsified part of the model
The first frozen holdout used an unmeasured 12 MiB setting:
predicted let threshold: 136,567
regression cross-check: 136,637
The plan predicted no minor GC at 130,000, two at 145,000, and no GC for capturedVar(145000). Across 21 fresh processes per target:
| Target | Predicted minor GCs | Observed | Predicted wall | Observed mean |
|---|---|---|---|---|
let, 130k |
0 | 0 in 21/21 | 5.55 ms | 4.78 ms |
let, 145k |
2 | 1 in 21/21 | 20.46 ms | 10.74 ms |
var, 145k |
0 | 0 in 21/21 | 5.52 ms | 4.94 ms |
The byte model correctly placed the boundary and predicted the direction of the timing jump. The event-count submodel failed: this V8 state produced one minor GC, not two, so the wall-time magnitude was overpredicted by almost 2×.
That failure is part of the result. It shows why semi-space growth and post-collection state cannot be hidden inside the phrase “GC time.”
A revised holdout predicted the step out of sample
After that failure, the two-event rule was restricted to the power-of-two protocol represented by all five training settings. A second plan was frozen for the never-measured (min=max=64 MiB, N=630k/680k) workload—an extrapolation beyond the 32 MiB training maximum:
snapshot-constrained threshold: 660,855
unconstrained regression: 651,815
Before running it, the plan predicted:
| Target | Minor GCs | Wall time |
|---|---|---|
let, 630k |
0 | 26.70 ms |
let, 680k |
2 | 105.45 ms |
var, 680k |
0 | 16.68 ms |
The 21-process holdout produced:
| Target | Observed minor GCs | Observed mean wall |
|---|---|---|
let, 630k |
0 in 21/21 | 23.89 ms |
let, 680k |
2 in 21/21 | 106.36 ms |
var, 680k |
0 in 21/21 | 17.21 ms |
For the above-threshold let target, the wall-time error was 0.9%. The predicted jump was 78.75 ms versus 82.48 ms observed, a 4.7% error. The predicted let/var ratio was 6.32× versus 6.18× observed, a 2.3% error.
This is the missing out-of-sample check: snapshot bytes plus a known semi-space setting and N predicted the GC side of the boundary and approximately how large the wall-time step would be before that workload was run.
It is still a local model. It predicts the first step for this fresh-process protocol, Node/V8 version, and code shape—not arbitrary GC histories.
GC attribution must be delayed
Node delivers GC PerformanceEntry objects asynchronously. Reading the observer immediately after a timing window can report zero even when collection occurred inside it.
The corrected workers:
- save every trial’s start and end time;
- allow pending entries to arrive on later
setImmediateturns; - match each entry by its own
startTimeandduration; - record
detail.kind, so only minor events define the threshold.
This procedure is implemented in src/gc-trial-worker.mjs. The event timestamps, durations, and young-space telemetry are retained per run rather than summarized away.
The residual is not the pure cost of a binding
After attributed GC time is removed, a mutator-time difference remains. Allocation profiles cannot decompose CPU time into context allocation, initialization, closure linking, generated code, tiering, and barrier paths.
One candidate can at least be bounded experimentally. The original captured value is an SMI, which does not require a heap-reference store. A matched control used either one prebuilt SMI or one prebuilt boxed object, outside the timed window, through the same factory path.
Heap snapshots verified identical topology in both arms: 10,000 closures, 10,000 unique direct contexts, and 40 B per context. The timing protocol used 30 independent processes per arm, nine GC-free trials per process, and the process median. Before seeing the data, practical equivalence was defined as a boxed/SMI ratio in [0.90, 1.10].
geometric mean SMI: 2.045 ms
geometric mean boxed: 2.028 ms
boxed / SMI: 0.992
90% CI: [0.906, 1.085]
Welch TOST p-value: 0.038
The confidence interval lies inside the equivalence bounds, so the tagged-reference initialization path is equivalent to the SMI path within ±10% in this kernel. It does not explain the residual at a practically large scale here.
This does not measure “all write barriers.” The context is newly allocated, so V8 may skip or fast-path a generational remembered-set update. A promoted context storing a young object would be a different experiment. The conclusion is deliberately narrow: pointer-valued context initialization in this code shape is not a remaining >10% explanation.
“Overlapping distributions” is not equivalence
The original application-like medians—3.63, 3.60, and 3.23 ms—were accompanied by overlapping intervals. That supports only “no stable multi-fold difference was established.” It does not prove equality.
The replacement experiment used 60 fresh processes. Each process created and drained 100,000 correct callbacks; all six execution orders were repeated ten times. The estimand was the paired mean log wall-time ratio. Equivalence was fixed in advance at ±10%—the largest difference this microbenchmark would call practically interchangeable and far below the original multi-fold claim—with two primary comparisons and Bonferroni-adjusted alpha = 0.025 per comparison.
| Comparison | Geometric mean ratio | 95% CI | TOST p | Equivalent? |
|---|---|---|---|---|
factoryVar / capturedLet |
0.872 | [0.710, 1.072] | 0.618 | No |
copiedVar / capturedLet |
0.870 | [0.727, 1.040] | 0.648 | No |
The formal test did not establish ±10% equivalence. The data remain compatible with a moderate advantage for the alternatives, and GC occurrence still varied between otherwise balanced fresh processes. The defensible claim is therefore weaker than “they are the same”:
This experiment found no stable multi-fold advantage among the semantically correct implementations, but it did not establish practical equivalence within ±10%.
That distinction is exactly what a TOST is for.
Why var is not an optimization
Replacing let with one shared var removes thousands of contexts by removing the requirement to preserve thousands of values:
capturedLet(10000): [0, 5000, 9999]
capturedVar(10000): [10000, 10000, 10000]
That is not a faster implementation of the same program. It is a different program.
A factory or local copy preserves the behavior, but it also preserves the main allocation cost:
for (var i = 0; i < N; i++) {
const value = i;
callbacks.push(() => value);
}
The optimization target is not the keyword. It is the retained topology: how many closures escape, how many independent environments they require, how much state they retain, and whether allocation occurs in one latency-sensitive batch.
What the experiment establishes
For this Node/V8 version and code shape:
- the first-GC threshold scales linearly with configured semi-space under the controlled protocol (
R² > 0.999for both cases); - threshold arithmetic independently estimates 39.96 additional B/iteration, while the heap graph measures one additional 40 B context;
- a frozen second holdout correctly predicts
0 → 2minor GCs and 106.36 ms wall time from a 105.45 ms point prediction; - constant SMI and boxed captures are equivalent within a predeclared ±10% bound for this initialization path;
- the application-like data do not establish ±10% equivalence among the three correct source forms.
It does not establish that let is generally slow, that retained bytes always equal allocation traffic, that --max-semi-space-size is the current young-generation size, or that every V8 state will produce the same number of scavenges.
The final lesson is more specific—and more useful—than the original benchmark headline:
Independent semantic identity requires independent state. In this V8 pattern, that state costs one 40-byte context per escaping closure; enough contexts move the program across a predictable GC boundary, where a moderate allocation difference can become a multi-fold wall-time step.
Top comments (0)