Kousa4 Stack
ArticlesCategories
Environment & Energy

V8's Mutable Heap Numbers: How a Small Change Boosted JavaScript Performance by 2.5x

Published 2026-05-09 07:22:21 · Environment & Energy

In the constant quest to make JavaScript faster, the V8 team recently took a deep dive into the JetStream2 benchmark suite. They discovered a surprising performance cliff in the async-fs benchmark, related to how numbers are stored and updated. By introducing a simple optimization—making heap numbers mutable—they achieved a 2.5x speedup in that benchmark, contributing to an overall score improvement. This guide answers your questions about the bottleneck, the fix, and what it means for real-world code.

What is the async-fs benchmark and why was it a focus for optimization?

The async-fs benchmark simulates a JavaScript file system with asynchronous operations. It's part of the JetStream2 suite, which V8 uses to measure real-world performance. During profiling, V8 noticed that this benchmark had a disproportionate performance cost compared to others. The culprit? A custom, deterministic implementation of Math.random that used a mutable seed variable. Every call to Math.random updated seed, and in the default V8 engine, that triggered a heap allocation. Fixing this inefficiency became a priority because even though the code is synthetic, similar patterns appear in production JavaScript, such as in games or simulations that rely on fast pseudo-random number generation.

V8's Mutable Heap Numbers: How a Small Change Boosted JavaScript Performance by 2.5x
Source: v8.dev

How does V8's ScriptContext store numbers like the seed variable?

In V8, a ScriptContext is an array of tagged values that holds variables accessible in a script. Each entry takes 32 bits on 64-bit systems. The least significant bit is a tag: 0 means the value is a Small Integer (SMI)—the actual integer is stored directly, left‑shifted by one. A tag of 1 means the entry holds a compressed pointer to a heap object. For numbers that aren't SMIs (like decimals or large integers), V8 stores them as HeapNumber objects on the heap. A HeapNumber is an immutable 64‑bit double, and the ScriptContext points to it. This design is efficient for SMIs but becomes costly when a number (like seed) is repeatedly updated because each update requires allocating a new HeapNumber object on the heap.

Why did the custom Math.random implementation cause a performance bottleneck?

The custom Math.random inside async-fs updates the seed variable on every call using a series of arithmetic and bitwise operations. Since seed is a floating‑point number (it holds values that aren't integers), it can't be stored as a SMI. Instead, every mutation creates a new immutable HeapNumber on the heap. The seed slot in the ScriptContext is a pointer to this HeapNumber. So each call to Math.random allocates a new object, takes memory, and triggers garbage collection overhead. Profiling showed that this allocation was a major contributor to the benchmark's poor performance. The more calls to Math.random, the more allocations—and the more pressure on the garbage collector, creating a significant performance cliff for the entire async-fs benchmark.

How did V8 optimize the storage of mutable heap numbers to improve performance?

V8's solution was elegantly simple: make HeapNumbers mutable for variables that are frequently updated. Instead of allocating a new HeapNumber on each assignment, the JavaScript engine now allows the existing HeapNumber's floating‑point value to be modified in‑place. This change was specifically applied to variables stored in ScriptContexts that undergo repeated mutations, like the seed in the custom Math.random. With mutable heap numbers, the seed slot remains a pointer to the same HeapNumber object; the numeric value inside that object is overwritten. This eliminates all allocation overhead for each call and drastically reduces GC pressure. The result is a 2.5x speedup in the async-fs benchmark—a dramatic improvement from a single, targeted change.

What impact did this optimization have on the JetStream2 benchmark scores?

The mutable heap numbers optimization directly boosted the async-fs sub‑score, which contributed to a noticeable increase in V8's overall JetStream2 score. While the exact overall percentage varies by test configuration, the 2.5x improvement in one benchmark lifted the total performance across the suite. This kind of optimisation is particularly valuable because JetStream2 is designed to mimic real‑world web applications. Even though the custom Math.random pattern is synthetic, it mimics code that appears in libraries and games. By removing the allocation bottleneck, V8 not only made the benchmark faster but also made real user experiences smoother—especially in asynchronous, I/O‑heavy applications that rely on random numbers.

Are patterns like this Math.random implementation common in real-world code?

Yes, while the specific custom Math.random in async-fs is deterministic for reproducible results, the pattern of frequently updating a numeric variable is very common. Think of physics engines updating velocity, game loops incrementing scores, or simulation code that re‑seeds a random generator. Any variable that is mutated on every iteration and stored as a heap number (not a small integer) would have suffered from the same allocation overhead. By making heap numbers mutable for these frequent-update scenarios, V8 now handles real‑world code much more efficiently. Developers writing performance‑sensitive JavaScript—especially in fields like gaming, data visualization, or server‑side processing—will benefit from this change without having to modify their code at all.