Random numbers: uniform, Gaussian, and why you never mod a float
a + Math.random() * (b - a) works because it's a linear rescaling of a uniform variable. ((Math.random() * 1000) % (b - a)) + a doesn't, because folding an uneven pool with modulo introduces bias — and shifting by a afterward doesn't fix it. The actual math behind both — plus the Gaussian, the other distribution everyone reaches for.
The issue
Say you need a random 3-digit number somewhere between a and b. The tempting one-liner is ((Math.random() * 1000) % (b - a)) + a — multiply by 1000 for three digits, mod by the width to fit inside the range, then shift up by a so it starts in the right place. It's wrong, in two separate ways, and it's worth being precise about both — and about why a + Math.random() * (b - a) is the version that actually works.
What a random number actually is
Nothing from Math.random() is random in the dice-roll sense — it's a pseudo-random number generator (PRNG), a deterministic algorithm that gives the same next value for the same internal state. V8 (Chrome, Node, Edge) seeds one with OS entropy once, then iterates xorshift128+ for every call after that — arithmetic on a 128-bit state, statistically indistinguishable from randomness unless you're specifically looking for the pattern.
It's engineered to behave like a draw from the continuous Uniform(0, 1) distribution: every value between 0 and 1 equally likely, at the resolution of a 64-bit float — 53 bits of mantissa, so about 2⁵³ (9 quadrillion) equally spaced points. Fine enough to treat as a true continuum for dice, shuffles, IDs, or sampling.
Uniform: every outcome, equally likely
A distribution is a rule for how likely each value is. Uniform(a, b) is the simplest: flat probability across the interval — density 1 / (b − a) inside it, zero outside. Every sub-interval of the same width has the same chance, no matter where you put it.
density: f(x) = 1 / (b − a) for a ≤ x ≤ b
f(x) = 0 otherwise
mean: (a + b) / 2
variance: (b − a)² / 12Scaling a uniform variable keeps it uniform
Here's why a + Math.random() * (b - a) is correct, not just conventional. If X is Uniform(0, 1), an affine transform — multiply by a constant, add a constant — keeps it exactly uniform, just stretched and shifted. Y = a + (b − a)·X lands anywhere in [a, b), and because the transform is linear, it stretches every sub-interval of [0, 1) by the same factor. Flat goes to flat.
The CDF proves it in one line: P(Y ≤ y) = P(X ≤ (y−a)/(b−a)) = (y−a)/(b−a) — exactly the CDF of Uniform(a, b). Exact for every draw, because it's a straight line doing the transforming.
// a float uniformly in [a, b)
const f = a + Math.random() * (b - a);
// an integer uniformly in [a, b], inclusive on both ends
const n = a + Math.floor(Math.random() * (b - a + 1));Where "multiply, then mod" goes wrong
This version does at least try to account for a: mod by the width (b - a) to get something in [0, b - a), then add a to slide the whole thing into place. It's still wrong, for two reasons. First: % in JavaScript is a true remainder, not integer division — Math.random() * 1000 is a float like 483.7291…, and 483.7291 % 7 is 6.729…, not the integer you pictured. You'd need Math.floor() first for the mod to mean anything.
Second, the one that survives the floor(): 1000 has nothing to do with (b - a). Reducing a fixed pool of integers modulo a number that doesn't evenly divide it is modulo bias — easiest seen with a deliberately dramatic example.
Say a = 200 and b = 800, so the width b - a is 600. Math.floor(Math.random() * 1000) % 600 maps numbers 0–599 to residues 0–599, but 600–999 map to residues 0–399 again — 600 mod 600 is 0, 999 mod 600 is 399. So residues 0–399 each come from two inputs out of 1000; residues 400–599 come from only one. The trailing + a doesn't fix any of that — it just relabels the same skew: residue 0 becomes output value 200, residue 550 becomes 750, and 200 is still twice as likely to come out as 750.
const a = 200, b = 800; // width: 600
const counts = new Array(b - a).fill(0);
for (let i = 0; i < 1_000_000; i++) {
const n = (Math.floor(Math.random() * 1000) % (b - a)) + a;
counts[n - a]++;
}
console.log(counts[0]); // ≈ 2000 — n = 200, hit by both 0 and 600 before the shift
console.log(counts[550]); // ≈ 1000 — n = 750, hit only once before the shiftEven a width that looks harmless bites: 1000 % 7 is 6, so a 7-wide range gives its first six values a 143-in-1000 chance each and the seventh only 142-in-1000 — a 0.7% skew no eyeball test catches. It's always biased whenever the pool size isn't an exact multiple of the range's width; only the size of the skew changes.
Why the float version mostly dodges this
So why does a + Math.random() * (b - a) get away with it? Not because floats are immune to modulo bias — because it isn't modulo reduction at all. Multiplying and adding is a linear rescaling of a continuum: no folding, no divisibility condition, because there's nothing to divide. The two stories do meet once you Math.floor() the result into a whole number — now you're bucketing a discrete set too. But the grid is ~2⁵³ points wide, so even an uneven split among a few hundred buckets misplaces probability by about one part in 2⁵³ — undetectable. Old rand() % n in C, with RAND_MAX often just 32767, produces a bias you can find with a spreadsheet. Same mechanism, wildly different scale.
The other distribution: Gaussian
Uniform isn't the only distribution worth knowing — just the one Math.random() gives you for free. Gaussian (normal) is the bell curve: values cluster near a mean μ and taper off symmetrically at a rate set by σ. Uniform says every value is equally plausible; Gaussian says values near the middle are common and extremes get rarer — a better model for height, measurement error, or exam scores than a flat line.
density: f(x) = (1 / (σ√(2π))) · e^(−(x−μ)²/(2σ²))
mean: μ
variance: σ²
~68% of the mass within ±1σ, ~95% within ±2σ, ~99.7% within ±3σGaussians show up everywhere because of the Central Limit Theorem: sum enough independent effects, however each is distributed, and the sum converges toward a Gaussian — the default shape for a noisy real-world quantity, and the wrong one for a die roll or a shuffled deck, which really are uniform. But Math.random() only ever hands you uniform; turning that into a Gaussian sample takes an explicit transform — Box–Muller: feed it two independent Uniform(0, 1) draws, get one exact standard-normal sample out.
function gaussianRandom(mean = 0, stdDev = 1) {
let u = 0, v = 0;
while (u === 0) u = Math.random(); // 0 would make log(u) = -Infinity
while (v === 0) v = Math.random();
const z = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
return z * stdDev + mean;
}The actual recipes
It collapses to one habit of mind: know which operation you're performing on the distribution. Multiply-and-add is a line stretched across a continuum — nothing to fold. Modulo is a fold: it collapses a set of size M onto n buckets, and unless M is an exact multiple of n, some buckets catch more of the fold than others. Every "obviously fine" random-number bug — including the one this issue opened with — is that same fold, showing up somewhere you didn't expect.