Skip to content
RandoKit
EN

Randomness

Choosing between Math.random() and crypto.getRandomValues()

What each JavaScript random API actually returns, why one is predictable, and how to write an unbiased integer picker with rejection sampling.

8 min readUpdated

JavaScript gives you two very different ways to get a random-looking number, and picking the wrong one has real consequences. One is fast and fine for things nobody will ever contest. The other is slower, more code, and the only reasonable choice the moment money, prizes, or fairness claims are involved.

What each API actually returns

Math.random()
returns a single floating-point number in the range [0, 1), generated by whatever pseudorandom algorithm the JavaScript engine ships internally (commonly xorshift128+ in V8). It takes no arguments and gives you no control over the underlying state.

function Math.random(): number   // [0, 1)

const x = Math.random();          // e.g. 0.7321...
const roll = Math.floor(x * 6) + 1; // naive 1-6 mapping, see below
Math.random signature and typical use

crypto.getRandomValues()
is a different shape entirely: it doesn't return a number, it fills a typed array you provide with random bytes, sourced from the browser's connection to the operating system's cryptographically secure generator.

function crypto.getRandomValues<T extends ArrayBufferView>(array: T): T

const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
const x = buf[0];   // a random 32-bit unsigned integer, 0 to 4294967295
crypto.getRandomValues signature and typical use

That difference in shape isn't cosmetic — filling an array instead of returning a scalar is precisely what makes batching efficient, which matters once you're drawing thousands of values (more on that below).

When Math.random() is the right tool

Math.random()
is well-suited to anything where unpredictability is a nice visual property, not a guarantee anyone relies on: staggering CSS animation delays so elements don't all pulse in sync, jittering a particle effect, shuffling the order of decorative background shapes, or picking a random loading message to display. If the result being "wrong" or "guessable" has zero real-world consequence, the extra machinery of a cryptographic generator buys you nothing.

Why it's the wrong tool for anything contested

The problem is predictability of internal state. Xorshift128+ and similar engine-internal PRNGs are fast precisely because they're simple linear operations over a small state (128 bits, in that case). Researchers have published working methods to observe a handful of consecutive

Math.random()
outputs from a page and reconstruct the internal state well enough to predict all future outputs from that same generator instance. You don't need to trust an academic paper to see why this matters: if you ran a raffle, a name picker, or a "who goes first" draw using
Math.random()
and someone with technical skill wanted to game it, the mathematics to do so already exists and is public. That's an unacceptable risk the moment a prize, a grade, a turn order, or any outcome a person could dispute is on the line — see how to run a fair giveaway for what "on the line" means in practice for a real drawing.

What a cryptographically secure generator promises — and what it doesn't

crypto.getRandomValues()
is backed by a CSPRNG, which gives a specific, narrow guarantee: even if an attacker sees every output the generator has ever produced, they cannot compute or meaningfully guess better than chance what the next output will be. That's a strong property, and it's exactly the property you want for a drawing where someone might try to predict or reverse-engineer the result.

It is not a guarantee about auditability or tamper resistance against whoever runs the page. A CSPRNG being unpredictable to an outside observer says nothing about whether the person operating the tool secretly swapped the list of names beforehand, re-ran the draw silently until they liked the result, or hard-coded an outcome. "Uses a secure random source" and "produces a result nobody, including the operator, could have influenced" are two different claims, and no client-side JavaScript API can prove the second one on its own — that requires a transparent process (visible participant list, a single visible draw, no re-rolling) around the tool, not just the tool itself. For more on how randomness works on RandoKit specifically, see the dedicated page.

Performance and batching

Calling

crypto.getRandomValues()
has more overhead per call than
Math.random()
because it typically crosses from JavaScript into the browser's native crypto layer. Calling it once per draw in a tight loop of thousands of iterations is wasteful. The fix is simple: ask for a large batch of random bytes in one call and consume them one at a time from a buffer.

function makeRandomPool(size = 1024) {
  const pool = new Uint32Array(size);
  let index = size; // force a refill on first use

  return function nextUint32() {
    if (index >= pool.length) {
      crypto.getRandomValues(pool);
      index = 0;
    }
    return pool[index++];
  };
}

const nextUint32 = makeRandomPool();
// nextUint32() is now cheap to call thousands of times
Batching random 32-bit values instead of calling per-draw

This is the kind of detail that matters when generating, say, a hundred thousand rows for the random number generator tool, or shuffling a large roster repeatedly with the random winner picker, but is irrelevant if you're drawing one winner from twenty names.

An unbiased integer helper with rejection sampling

Neither

Math.floor(Math.random() * n)
nor a naive
randomByte % n
produces a perfectly uniform integer whenever
n
doesn't evenly divide the number of possible source values — see the worked byte-to-die example in how random number generators work for the arithmetic. The fix that both removes the bias and uses a secure source is to draw 32-bit integers and reject any value that would create an uneven remainder:

function secureRandomInt(maxExclusive: number): number {
  if (maxExclusive <= 0) throw new Error("maxExclusive must be positive");

  // Largest multiple of maxExclusive that fits in a 32-bit range
  const range = 0x100000000; // 2^32
  const limit = range - (range % maxExclusive);

  const buf = new Uint32Array(1);
  let value: number;
  do {
    crypto.getRandomValues(buf);
    value = buf[0];
  } while (value >= limit); // reject and redraw on the biased leftover

  return value % maxExclusive;
}

// secureRandomInt(6) is uniform over 0-5 with no modulo bias
Unbiased random integer in [0, max) via rejection sampling

The rejection loop runs again only on the rare draws that fall in the unevenly-divided leftover range, so in practice it almost always finishes on the first try, and the cost of an occasional extra draw is trivial compared to the correctness it buys.

Practical guidance: which one, when

  • Use Math.random(): UI animation timing, decorative shuffles, placeholder content, anything where a technically savvy user predicting the outcome causes no harm.
  • Use crypto.getRandomValues() with rejection sampling: picking a name on the wheel of names, drawing a winner with the random winner picker, generating numbers for a lottery or bracket seed, or any situation where someone could reasonably ask "how do I know this wasn't rigged or predictable?"

The takeaway

The two APIs aren't interchangeable conveniences — they trade off speed and simplicity against predictability guarantees, and the right choice depends entirely on whether a human stands to gain or lose something from the outcome. For a deeper look at what's happening underneath both approaches, including seeds, entropy, and why modulo bias shows up at all, read how random number generators work.

Tools used in this guide