Back to Blog
Technicaljavascriptsecurityrandomness

How Computers Generate Random Numbers (and When It Matters)

An engineer's explainer on the difference between Math.random() (a deterministic PRNG) and crypto.getRandomValues() (a CSPRNG), grounded in loopaloo's real dice roller, password generator, and ID generator code, including modulo bias and rejection sampling.

LoopalooPublished May 8, 20269 min read

A computer that runs the same program twice with the same inputs is supposed to produce the same output twice. That determinism is the whole point of a CPU. So the phrase "computer-generated random number" is, taken literally, a contradiction. What computers actually do is run an algorithm that produces a stream of numbers spread out enough to look random, or else they reach outside the deterministic world entirely and sample some physical noise the operating system has collected.

Which of those two things you get matters enormously. One of them is fine for shuffling a deck or rolling a die. The other is what stands between an attacker and your password. Picking the wrong one is one of the more common and most quietly dangerous mistakes in web code, because the broken version still looks random when you eyeball the output. The bug never shows up in a screenshot.

This post walks through the difference using real code from loopaloo's tools: the dice roller that leans on Math.random(), the password generator that uses crypto.getRandomValues(), and the ID generator whose behavior depends entirely on which ID type you pick.

Math.random() is a deterministic PRNG

Math.random() returns a floating-point number in the range [0, 1). Under the hood, every JavaScript engine implements it as a pseudorandom number generator (PRNG): a deterministic function that holds some internal state, returns a number derived from that state, then advances the state for next time. V8 (Chrome, Node) uses an algorithm called xorshift128+. Other engines use their own, but they share the same shape.

The "pseudo" part is the important word. A PRNG is seeded once, usually from some entropy the engine grabs at startup, and from then on the entire sequence is a pure function of that seed. If you knew the seed and the algorithm, you could reproduce every "random" number the page will ever generate, in order. You generally cannot read the seed from inside JavaScript, and engines reseed periodically, so this is not a practical attack you can mount from a webpage. But the property is real, and it is the reason the spec explicitly warns against using Math.random() for anything security-sensitive.

There are two other things worth knowing about Math.random():

  • It is fast and has no setup cost. You call it, you get a number.
  • It carries no guarantee about statistical quality across engines. It is good enough that you will not see patterns by eye, and good enough for games, but it is not designed to pass rigorous statistical test suites, and it is emphatically not unpredictable to an adversary.

For a game, none of that matters. Predictability is a security concern, and a dice roller has no secrets to protect.

How the dice roller uses it

loopaloo's dice roller (src/components/tools/games/DiceRoller.tsx) generates a roll with exactly the idiom every tutorial teaches:

results: Array(config.count).fill(0).map(() =>
  Math.floor(Math.random() * config.sides) + 1
)

Math.random() gives a float in [0, 1). Multiply by config.sides (say 6) and you get a float in [0, 6). Math.floor knocks it down to an integer in {0, 1, 2, 3, 4, 5}, and + 1 shifts that to {1, 2, 3, 4, 5, 6}. Each face is equally likely because the [0, 1) interval is being chopped into six equal slices.

The same file uses Math.random() for cosmetic, non-numeric work too:

const generateId = () => Math.random().toString(36).substr(2, 9);

That builds a short throwaway key for React's list rendering. It does not need to be unguessable, only distinct enough within one session, so a PRNG is exactly right. Using a cryptographic generator here would be slower and would signal an importance the value does not have.

This is the healthy use of Math.random(): games, animations, jitter, picking a random tip to show, shuffling a non-money playlist. Anywhere the cost of an attacker predicting the value is zero.

crypto.getRandomValues() is the secure one

When the output is a secret, you need a cryptographically secure pseudorandom number generator (CSPRNG). In the browser that is the Web Crypto API:

const array = new Uint32Array(16);
crypto.getRandomValues(array);

getRandomValues fills a typed array with random values drawn from the operating system's secure entropy source (on Linux that is the getrandom syscall and /dev/urandom, with equivalents on other platforms). The defining property of a CSPRNG is that even an attacker who has seen a long run of past outputs cannot predict the next one with better than negligible advantage. That is a much stronger promise than "looks random," and it is the promise you need for passwords, tokens, session IDs, encryption keys, and password-reset links.

The tradeoffs versus Math.random():

Math.random()crypto.getRandomValues()
Type of generatorPRNGCSPRNG
Predictable from past outputsIn principle, yesNo (that is the guarantee)
Outputone float in [0, 1)fills a typed integer array
Right forgames, shuffles, jitter, UI keyspasswords, tokens, keys, secrets
Throws on misuseneverthrows if you request too many bytes at once

One sharp edge: getRandomValues has a per-call quota of 65536 bytes. Ask for more in a single call and it throws a QuotaExceededError. For anything human-sized, like a 128-character password, you are nowhere near the limit.

How the password generator uses it

The password generator (src/components/tools/web/PasswordGenerator.tsx) is the security-sensitive tool, so it reaches for Web Crypto:

const array = new Uint32Array(options.length);
crypto.getRandomValues(array);

let newPassword = '';
for (let i = 0; i < options.length; i++) {
  newPassword += charset[array[i] % charset.length];
}

It allocates one 32-bit unsigned integer per password character, fills the whole array with cryptographic randomness in a single call, then maps each integer onto a character in the chosen alphabet. Because the random source is a CSPRNG, an attacker who somehow saw your last ten generated passwords still learns nothing about the next one. That is the correct foundation for a tool whose entire job is producing secrets.

Notice what it does not depend on for entropy: nothing here touches Math.random(). The strength meter elsewhere in the file computes entropy as length * log2(poolSize), where poolSize is the size of the character classes the finished password actually contains. That formula is the right one precisely because every character is an independent uniform draw from the pool. The math only holds if the underlying generator is uniform and unpredictable, which is exactly what Web Crypto buys you.

Modulo bias: the bug hiding in % charset.length

There is a subtlety in that mapping step, and it is the most commonly botched part of "pick a random item from a list." Look again:

charset[array[i] % charset.length]

array[i] is a uniform random integer in [0, 2^32). The charset might have, say, 70 characters. 2^32 is not a multiple of 70. So when you wrap the range around with %, the first few residues get one extra value's worth of probability than the last few. The low characters in the alphabet come up very slightly more often than the high ones. That is modulo bias.

Here is the effect at a tiny scale you can reason about by hand. Imagine your random source produces 0..9 (ten equally likely values) and you want a number 0..2 using % 3:

Random value% 3
0, 3, 6, 90
1, 4, 71
2, 5, 82

Value 0 comes up four times out of ten; values 1 and 2 only three times each. That is a 33% over-representation of one outcome. The reason is that 10 does not divide evenly by 3; the leftover (10 mod 3 = 1 value) spills onto the front of the range.

In the password generator the bias is real but tiny, because the imbalance is roughly charset.length / 2^32. With a 70-character set, the over-represented characters are favored by a factor on the order of one part in sixty million. That does not meaningfully dent the entropy of a human-length password, which is why the code is reasonable as written. But the principle is what bites people when the divisor is large relative to the random range, and the textbook fix is rejection sampling: compute the largest multiple of charset.length that fits in your range, and if a draw lands above it, throw it away and draw again.

function unbiasedIndex(n) {
  const max = Math.floor(2 ** 32 / n) * n; // largest clean multiple
  const buf = new Uint32Array(1);
  let x;
  do {
    crypto.getRandomValues(buf);
    x = buf[0];
  } while (x >= max);     // reject the lopsided tail
  return x % n;           // now perfectly uniform
}

You discard a small fraction of draws in exchange for an exactly uniform result. This is the technique behind well-written token and ID libraries (nanoid, for instance, masks and rejects rather than taking a raw modulo), and it is worth recognizing the pattern even when the bias in your specific case is negligible.

The ID generator: it depends which ID you pick

The ID generator (src/components/tools/converter/UuidGenerator.tsx) is the most interesting case, because the answer to "is this secure randomness?" changes per ID type, and the code is honest about it.

UUID v4 is generated with the platform primitive:

const generateUuidV4 = (): string => {
  return crypto.randomUUID();
};

crypto.randomUUID() is part of Web Crypto. It produces a version-4 UUID whose 122 random bits come from the same secure entropy source as getRandomValues. That is exactly what you want from a v4 UUID: collision resistance and, where it matters, unpredictability, with no hand-rolled bit-twiddling to get wrong.

The other ID types in the tool use Math.random() for their random components. ULID's 16-character tail, NanoID's characters, CUID's segments, and the worker, datacenter, and sequence fields of the Snowflake generator all look like this:

randomChars.push(ULID_ALPHABET[Math.floor(Math.random() * 32)]);

This is a deliberate and defensible choice for a generator-and-inspector utility. These IDs exist to be unique and, in ULID and Snowflake's case, time-sortable; their value is in ordering and collision avoidance, not secrecy. ULID and Snowflake deliberately put a timestamp in the high bits, so they are not meant to be unguessable in the first place. Using Math.random() for the filler bits keeps the code simple and is appropriate for demonstrating the formats.

The caveat, stated plainly: if you need an identifier to be unguessable (a session token, an unsubscribe link, an API key, a password-reset URL), do not use these Math.random()-backed types. Reach for UUID v4 here, which is crypto-backed, or generate a token with crypto.getRandomValues() directly. A Snowflake ID with a visible timestamp and small random field is trivially enumerable, and that is by design, not a flaw, as long as you never treat it as a secret.

A quick decision guide

The whole topic collapses to one question: what happens if someone predicts this number?

If the value is...UseBecause
A dice roll, shuffle, animation jitterMath.random()Prediction has zero cost; speed and simplicity win
A React key or throwaway labelMath.random()Only needs to be locally distinct
A password, token, or keycrypto.getRandomValues()Must be unpredictable to an attacker
A v4 UUIDcrypto.randomUUID()Crypto-backed and standard-compliant
A sortable ID where order is the pointtimestamp plus PRNG (ULID/Snowflake)Secrecy is not a goal; ordering is

And one rule for mapping a random integer onto a range of size n: a raw % n introduces modulo bias whenever n does not divide the range evenly. The bias is usually tiny for small alphabets against a 32-bit range, but use rejection sampling when you need an exactly uniform result.

The reason any of this is worth caring about is that the failure mode is invisible. A password built on Math.random() produces gibberish that looks every bit as strong as one built on a CSPRNG. The difference only surfaces the day someone with the right knowledge decides to predict it. Choosing the right generator up front is the cheapest security decision you will ever make, because it costs nothing and the code is no harder to write.

If you want to see these choices in practice, try loopaloo's Dice Roller (PRNG, exactly as it should be), the Password Generator (Web Crypto, for real secrets), and the UUID Generator (crypto-backed v4 alongside several sortable, non-secret ID formats).

Related Tools

Related Articles

Try Our Free Tools

200+ browser-based tools for developers and creators. No uploads, complete privacy.

Explore All Tools