DEV Community

Cover image for Why let Looked 7 Slower Than var: Closure Contexts and GC Thresholds in V8
Andrii Volynets
Andrii Volynets

Posted on • Edited on

Why let Looked 7 Slower Than var: Closure Contexts and GC Thresholds in V8

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:

let is much slower than var.

The problem is that the two programs do not have the same semantics.

for (var i = 0; i < N; i++) {
  callbacks.push(() => i);
}
Enter fullscreen mode Exit fullscreen mode

All callbacks share one binding. After the loop, every function returns N.

for (let i = 0; i < N; i++) {
  callbacks.push(() => i);
}
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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. 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. save every trial’s start and end time;
  2. allow pending entries to arrive on later setImmediate turns;
  3. match each entry by its own startTime and duration;
  4. 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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.999 for 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 → 2 minor 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)