Where a computer’s random numbers come from, why Math.random() isn’t for secrets, and the two classic bugs that make “random” results unfair.
Pseudo-random vs cryptographically secure
Computers are deterministic, so almost every random number you’ve used came from a pseudo-random number generator (PRNG): a formula that turns an internal state into an output and a new state. Given the same starting state, or seed, it produces the same sequence every time. That’s useful for reproducible simulations and tests, and dangerous for anything secret.
JavaScript’s Math.random() is a fast PRNG (xorshift128+ in all three major engines). Researchers have shown its state can be recovered from a handful of outputs, after which every later value is predictable. The ECMAScript specification says nothing about its quality at all.
A CSPRNG adds two guarantees: seeing past outputs doesn’t help predict future ones, and its state is seeded and regularly refreshed with real entropy from the operating system. In the browser that’s crypto.getRandomValues(); on servers it’s getrandom() on Linux, BCryptGenRandom on Windows, and wrappers like Python’s secrets module and Node’s crypto.randomBytes.
Need
Use
Passwords, tokens, keys, session IDs
CSPRNG (crypto.getRandomValues, secrets)
Unique IDs
crypto.randomUUID() or UUID v7
Reproducible test fixtures, simulations
Seeded PRNG (e.g. Faker with faker.seed(42))
Animations, game effects
Math.random() is fine
Where the entropy comes from
The operating system collects unpredictability from the physical world: the exact nanosecond timing of interrupts, disk and network events, and dedicated hardware noise sources such as the RDRAND/RDSEED instructions on Intel and AMD CPUs. The Linux kernel mixes these into a pool and uses them to seed a ChaCha20-based generator. Once it has gathered enough entropy (256 bits), its output is safe to use indefinitely.
See modulo bias happen
Imagine a generator that gives a random number from 0 to 15 (4 bits), and you want a die roll from 1 to 6. The obvious code is x % 6. But 16 values don’t split evenly into 6 buckets, so the first 4 results get one extra source value each. Real generators use 32 or 64 bits, which makes the bias smaller, but it never goes away unless the range divides evenly.
Method
48,000 draws. Fair share per result: 16.7%. With %, results 1–4 come up 50% more often than the rest.
RandomKit uses rejection sampling for every integer it produces, from dice to password characters to list shuffles.
Bug one: modulo bias
The demo above shows the first classic mistake. Turning random bits into a number in a range with % favours the low results whenever the range doesn’t divide the generator’s output range evenly. The fix is rejection sampling: find the largest multiple of n that fits, discard any draw above it, and draw again. On average that needs fewer than two draws, and every result is exactly equally likely.
Floating-point shortcuts like Math.floor(Math.random() * n) have the same problem in a quieter form, because a double has only 253 possible values. That doesn’t matter for games, but for draws that need to be provably fair, integers with rejection sampling are the right tool.
Bug two: the naive shuffle
The second mistake is shuffling by swapping every item with a random position anywhere in the list. It feels random, but for n items it makes nn equally likely swap sequences, and that number can’t be divided evenly among the n! possible orderings, so some orderings come up more often. The list randomizer has a live demo comparing it with the correct Fisher–Yates shuffle. Sorting with a random comparator (arr.sort(() => Math.random() - 0.5)) is also biased, and how badly depends on the browser’s sort algorithm.
For that same learning-by-doing feeling, ahaboo offers explainers you can manipulate directly, whether it's tracing why the Moon changes shape or zooming into a leaf to follow photosynthesis.
Randomness that looks wrong but isn’t
People expect random sequences to alternate more than they do. Real randomness is streaky: 100 coin flips usually contain a run of six or more. The coin flip tool reports the longest streak so you can see it. Adding random numbers together produces a bell curve rather than a flat line, which the random number generator shows with dice.
How RandomKit generates values
All randomness comes from crypto.getRandomValues, read in 32-bit words.
Integers in a range use rejection sampling; floats use 53 random bits.
Shuffles, picks and teams use Fisher–Yates.
UUID v4 and v7 follow RFC 9562; fake data uses the Faker library with its generator swapped for the CSPRNG.
Nothing is generated on a server, and nothing you generate is transmitted.
Questions people ask
Is Math.random() secure?
No. Math.random() is a fast pseudo-random generator (xorshift128+ in V8, SpiderMonkey and JavaScriptCore). Its internal state can be recovered from a few outputs, after which every future value is predictable. Use crypto.getRandomValues() or crypto.randomUUID() for tokens, passwords and IDs.
What is a CSPRNG?
A cryptographically secure pseudo-random number generator: an algorithm, seeded with real entropy from the operating system, whose output can’t be predicted or told apart from true randomness without knowing its internal state. Linux’s getrandom(), Windows’ BCryptGenRandom and the browser’s Web Crypto API are all CSPRNGs.
Is a computer ever truly random?
The seed is. Operating systems collect entropy from unpredictable physical events such as interrupt timing and hardware noise sources like Intel’s RDRAND. A CSPRNG then stretches that seed into as many random bytes as you need. For practical purposes the result is as good as true randomness.
When should I use a seeded generator instead?
When you need reproducibility: simulations, procedural generation, and tests that must produce the same fixtures on every run. Libraries like Faker accept a seed for that purpose. Never use a seeded generator for secrets.