When you need to shuffle an array in JavaScript, the naive approach using Math.random() and Array.prototype.sort() is both biased and insecure. In this post I'll walk through implementing an unbiased Fisher-Yates shuffle backed by a cryptographically secure pseudo-random number generator (CSPRNG).
Why Math.random() isn't enough
Math.random() is fine for cosmetic randomness, but it has two problems:
-
It's not uniformly safe for shuffling. The common
arr.sort(() => Math.random() - 0.5)trick produces a biased distribution — some permutations are far more likely than others. - It's predictable. The underlying PRNG (xorshift128+ in V8) is not cryptographically secure. Given enough outputs, its internal state can be reconstructed.
For anything where fairness must be verifiable — lotteries, card dealing, randomized A/B bucketing, security tokens — you need a CSPRNG.
The Fisher-Yates algorithm
Fisher-Yates (a.k.a. the Knuth shuffle) produces a perfectly uniform permutation in O(n) time. The idea: walk the array from the last index down to the first, and for each position i, swap it with a randomly chosen index j where 0 <= j <= i.
function fisherYates(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
This is unbiased — but it still uses Math.random(). Let's fix the randomness source.
Adding a CSPRNG with the Web Crypto API
Browsers and Node expose crypto.getRandomValues(), which fills a typed array with cryptographically strong random values. The tricky part is converting those raw bytes into an unbiased integer in a range [0, max). Naive modulo (value % max) introduces modulo bias when max doesn't divide evenly into 2^32. We solve it with rejection sampling:
function secureRandomInt(maxExclusive) {
// Smallest number of bits that can represent maxExclusive
const range = maxExclusive;
const maxUint32 = 0xFFFFFFFF;
// Largest multiple of range that fits in uint32, for rejection sampling
const limit = maxUint32 - (maxUint32 % range);
const buf = new Uint32Array(1);
let x;
do {
crypto.getRandomValues(buf);
x = buf[0];
} while (x >= limit);
return x % range;
}
function secureShuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = secureRandomInt(i + 1);
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
The do...while loop discards any value that falls in the "uneven tail" of the uint32 range, guaranteeing each outcome is equally likely. On average it rejects less than one sample per call, so performance is effectively unchanged.
Verifying uniformity
A quick way to sanity-check: shuffle a small array many times and tally how often each permutation appears. With a correct implementation, all n! permutations converge to roughly equal frequency.
const counts = {};
for (let i = 0; i < 600000; i++) {
const perm = secureShuffle([1, 2, 3]).join('');
counts[perm] = (counts[perm] || 0) + 1;
}
console.table(counts); // each of the 6 permutations ≈ 100000
If you run the biased sort() version through the same harness, you'll immediately see the skew — some permutations show up 50% more often than others.
Provably-fair: taking it one step further
In systems where users must be able to audit that an outcome wasn't manipulated, developers go beyond a CSPRNG and adopt a provably-fair scheme: the server commits to a hashed server seed up front, the client contributes its own seed, and the final result is derived from HMAC(serverSeed, clientSeed:nonce). After the round, the server reveals the seed so anyone can recompute and verify the outcome. The shuffle algorithm stays the same — Fisher-Yates — but the entropy source becomes a deterministic, auditable function of the committed seeds.
Takeaways
- Never use
sort(() => Math.random() - 0.5)for real shuffling — it's biased. - Use Fisher-Yates for an O(n) unbiased permutation.
- Swap in
crypto.getRandomValues()+ rejection sampling when randomness must be secure or fair. - For auditable fairness, layer a commit-reveal provably-fair scheme on top.
Happy (secure) shuffling.












