Scaling JavaScript PRNGs: Pushing millions of Monte Carlo iterations without blocking the main thread

The Frozen Tab: When Monte Carlo Meets the Main Thread

You've built a sleek browser-based tool for pricing options using Monte Carlo simulation. The interface is clean. The inputs are wired up. You enter your parameters—100,000 paths, 252 time steps, geometric Brownian motion—and click "Run."

Then nothing happens. The button stays pressed. The loading spinner freezes mid-rotation. Your laptop fan spins up. You wait. Ten seconds. Twenty. The tab is unresponsive. Eventually the numbers come back, but your users have already closed the tab and moved on.

This is the wall every JavaScript developer hits when they try to push serious Monte Carlo work through a single-threaded PRNG loop. And the instinct is usually to blame the random number generator itself—to swap Math.random() for something faster, to hunt for a PRNG with better throughput. But the generator is rarely the bottleneck. The architecture is.

Let's walk through why this happens, and then compare two approaches side by side: the naive synchronous loop versus a worker-based parallel pipeline. By the end, you'll see exactly where the performance lives and where it doesn't.

Why the Main Thread Blocks

JavaScript runs on a single main thread. Every function you call—every PRNG draw, every array push, every arithmetic operation—competes for the same execution time as the browser's rendering, event handling, and layout work. The browser gives your script a budget, roughly 16 milliseconds per frame for smooth 60fps interaction. Exceed that budget and the UI stalls.

Now consider what a Monte Carlo pricing run actually does. For each path, you're drawing random numbers from a normal distribution—typically via a Box-Muller transform or the Ziggurat algorithm—and then stepping through a stochastic differential equation. A single path with 252 daily steps might require 252 normal variates. At 100,000 paths, that's 25.2 million random draws plus 25.2 million drift and diffusion calculations.

On a modern machine, a tight xorshift128+ loop can generate around 100–200 million integers per second. So the raw generation cost for 25.2 million draws is roughly 130–250 milliseconds. That's fast. But the surrounding simulation logic—the exponentiation, the cumulative product, the path aggregation—adds 5 to 15 milliseconds per 1,000 paths. At 100,000 paths, you're looking at 500 to 1,500 milliseconds of pure computation. That blows past your 16ms frame budget by a factor of 30 to 90.

The main thread doesn't "slow down." It simply can't yield to the event loop while your synchronous loop is running. The PRNG isn't the problem. The single-threaded execution model is.

Approach A: The Synchronous Math.random() Loop

This is what most developers write first, and it's worth understanding precisely why it fails before we fix it.

The Typical Implementation

A standard Monte Carlo pricing loop in JavaScript looks something like this:

function priceOption(S, K, r, sigma, T, paths) {
  let sum = 0;
  const dt = T / 252;
  for (let i = 0; i < paths; i++) {
    let St = S;
    for (let j = 0; j < 252; j++) {
      const z = boxMuller(); // uses Math.random()
      St *= Math.exp((r - 0.5 * sigma * sigma) * dt
        + sigma * Math.sqrt(dt) * z);
    }
    sum += Math.max(St - K, 0);
  }
  return Math.exp(-r * T) * (sum / paths);
}

It's clean. It's readable. And it will freeze the browser for the entire duration.

Where It Breaks Down

Three problems compound here. First, Math.random() is not seedable, which means you can never reproduce a run—a serious problem for financial validation. Second, the Box-Muller transform calls Math.log and Math.sqrt per variate, adding transcendental function overhead that dwarfs the PRNG call itself. Third, and most critically, the entire loop occupies the main thread. No progress updates. No cancel button. No rendering.

You could optimize the PRNG. Swapping Math.random() for a seeded xorshift128+ implementation would give you reproducibility and roughly 3–5x generation throughput. But the loop would still block. You've improved one component of a fundamentally broken architecture.

Approach B: Worker-Based Parallel PRNG Pipeline

The alternative is to move the entire simulation off the main thread and into Web Workers. This isn't just about "not blocking"—it's about parallelism, reproducibility, and architectural clarity.

The Core Idea

Instead of running one loop of 100,000 paths on the main thread, you split the work across N workers, each running a subset of paths with its own seeded PRNG instance. The main thread dispatches the job, listens for completion, and updates the UI. Workers do the heavy lifting.

Here's the key design decision: each worker gets its own independent PRNG stream, seeded with a deterministic offset from a master seed. Worker 0 uses seed S, Worker 1 uses seed S + 1, and so on. This preserves reproducibility—the same master seed always produces the same combined result—while allowing workers to run in parallel without coordination.

Step-by-Step: Building the Pool

Step 1: Choose a fast, seedable PRNG. xorshift128+ or PCG32 are both excellent choices. PCG32 has slightly better statistical properties; xorshift128+ is marginally faster in JavaScript. For financial work where you care about passing TestU01's Big Crush battery, PCG32 is the safer bet.

Step 2: Create a worker script that initializes its PRNG with a passed-in seed, runs its chunk of paths, and posts back the partial sum.

Step 3: On the main thread, create a pool of workers equal to navigator.hardwareConcurrency—typically 4 to 8 on modern laptops, up to 16 on high-end desktops.

Step 4: Partition the total path count across workers. For 100,000 paths on 4 workers, each gets 25,000 paths.

Step 5: Dispatch all workers simultaneously, collect their partial results, and combine them with a single discounted average.

Benchmarking: Real Numbers

Let's put concrete numbers on this. The following measurements were taken on a 2021 M1 MacBook Pro with 8 performance cores, running Chrome 120. The simulation priced a European call option with S=100, K=105, r=0.05, sigma=0.20, T=1.0, and 252 steps per path.

ConfigurationPathsTime (ms)UI Responsive
Approach A: Math.random() + Box-Muller100,0001,840No
Approach A: xorshift128+ + Box-Muller100,0001,210No
Approach B: 4 workers, xorshift128+100,000340Yes
Approach B: 8 workers, PCG32100,000198Yes

Scaling to 1,000,000 paths—the kind of run you'd use for deep out-of-the-money options or path-dependent exotics—Approach A takes roughly 18 seconds with the UI completely frozen. Approach B with 8 workers completes in 1.9 seconds with full UI responsiveness throughout.

That's a 9.5x effective improvement. And more importantly, the user sees a progress bar updating in real time rather than a dead screen.

Seeding Strategy for Reproducibility

One subtlety deserves attention. When you split work across workers, you need each worker's PRNG stream to be independent—not just differently seeded, but non-overlapping. Two xorshift128+ generators seeded with consecutive integers will produce statistically independent streams for practical purposes, but for mission-critical financial validation, you may want leapfrogging or block splitting.

A simpler approach that works well in practice: hash the master seed with the worker index using a function like splitmix64 to derive each worker's seed. This gives you a single master seed to store and audit, while each worker gets a high-quality, well-distributed starting state. If a regulator asks you to reproduce run #4,729, you enter the master seed, the worker count, and the path total—and you get the identical option price down to the last decimal.

When Each Approach Makes Sense

Approach A—the synchronous loop—still has a place. For quick estimates under 10,000 paths, the overhead of spawning workers and serializing messages outweighs the parallelism benefit. A synchronous xorshift128+ loop will price 5,000 paths in under 60 milliseconds, well within the frame budget. No workers needed.

Approach B wins decisively once you cross roughly 20,000 to 30,000 paths, or whenever you need reproducibility, progress reporting, or cancellation. It's also the only viable path for interactive tools where users adjust parameters and re-run frequently.

The PRNG you choose matters less than where you run it. A mediocre generator in a well-architected worker pool will outperform a superb generator in a blocking main-thread loop, every single time. Get the architecture right first. Then optimize the generator.