Draw random numbers

Roll dice, pick winners, shuffle lists and make key material from values nobody can predict, with every value in range equally likely.

Use this whenever somebody would love to guess the result: a raffle, who plays whom, a retry delay, a key. The numbers come from the platform's secure generator, and when you ask for a number in a range, every value in it is equally likely, which is harder to get right than it sounds. Concepts explains why Math.random() and % n both fall short.

#Before you start

  • It runs on Node.js 22.13 or later (24 or later on the 24 line), Bun, Deno and browsers. Every function is synchronous except randomJitter, which returns a promise.
  • Upper bounds are exclusive: secureRandomNumber(1, 7) returns 1 to 6. Both bounds must be safe integers, and the range at most 2³² values wide.
  • The draws can't be seeded. To replay a sequence, record the values; you can't regenerate them.
  • secureShuffle reorders the array you pass. Copy it first when the caller still needs the original order.

#Pick winners, roll dice, make a key

Most apps need some mix of these four draws, and each one is a single call.

fair-draws.ts
/** `count` distinct winners. The copy leaves the caller's list in its order. */
export function pickWinners(entries: readonly string[], count: number): string[] {
  return secureShuffle([...entries]).slice(0, count);
}

/** One roll of a six-sided die: 1 to 6. The upper bound is exclusive. */
export function rollDie(): number {
  return secureRandomNumber(1, 7);
}

/** A delay between 500 and 999 ms, so clients that failed together retry apart. */
export function retryDelayMs(): number {
  return secureRandomNumber(500, 1000);
}

/** 32 random bytes: key material for HMAC or encryption. */
export function newKeyMaterial(): Uint8Array {
  return secureRandomBytes(32);
}
  • Winners: secureShuffle shuffles the array you give it, in place, and hands it back. Shuffling a copy keeps the entry list in sign-up order for the next draw.
  • Die: secureRandomNumber(min, max) can return min but never max, which is why a six-sided die asks for 1 to 7. With one argument, the range starts at 0.
  • Retry delay: when a burst of clients fails at once, spreading their retries over 500 ms stops them all knocking again in the same millisecond.
  • Key material: secureRandomBytes(32) gives you a fresh Uint8Array, ready for importKey or one of the codecs.

#Skip values or wait a random time

Sometimes you want a range with holes in it, or a pause of unpredictable length. The last argument of secureRandomNumber lists values to skip, and randomJitter waits a random number of milliseconds:

secureRandomNumber(1, 7, [6]); // 1 to 5
await randomJitter(100, 300); // resolves after 100 to 299 ms

randomJitter makes a timing difference harder to measure, but the difference is still there underneath. For a check whose timing must not leak at all, see Compare secrets.

#When it fails

Bad bounds and skip lists are setup mistakes, so they throw an UnsecureError. A runtime without working randomness throws PLATFORM:

SituationWhat happens
max is not greater than min, or secureRandomNumber(0)Throws OUT_OF_RANGE.
A bound is fractional, NaN or beyond Number.MAX_SAFE_INTEGERThrows OUT_OF_RANGE.
The range is wider than 2³² valuesThrows OUT_OF_RANGE.
The skip list covers every value in rangeThrows OUT_OF_RANGE, rather than looping forever.
The skip list is not a Set or an iterable, such as a stringThrows INVALID_TYPE.
secureRandomBytes length is negative, fractional or 2³¹ or moreThrows OUT_OF_RANGE.
randomJitter bound is fractional, negative or null, or max is below minThrows OUT_OF_RANGE synchronously, before any promise exists, so a .catch() on the call never sees it.
The runtime has no working crypto.getRandomValuesThrows PLATFORM, with the runtime's error as cause.

#Options

Only a few calls have an argument you can leave out:

CallDefaultNotes
secureRandomNumber(max)min is 0Returns 0 to max - 1.
randomJitter()0 to 99 msrandomJitter(max) waits 0 to max - 1, and randomJitter(min, max) waits min to max - 1. When min equals max, it waits exactly min.
secureShuffle(array, generator)the shared generatorTakes a private generator, described under Going further.
resetEntropy({ refill })refill: falsetrue fills the buffer again at once instead of on the next draw.

#Going further

These come up in tests, in snapshots and at start-up:

  • Shared and private buffers: secureRandomNumber, secureRandomBytes up to 64 bytes, randomJitter, secureShuffle and every UUID draw from one shared 4 KiB buffer of random bytes. const rng = createSecureRandomGenerator() gives you a generator with a 1 KiB buffer of its own, so its draws share nothing with the rest of the process. rng.next(1, 7) draws like secureRandomNumber, and secureShuffle(seats, rng) shuffles from it.
  • Snapshots and checkpoints: a process restored from a memory snapshot wakes up with the same buffer as every other copy, and would replay the same bytes. While Node.js builds a startup snapshot the buffer holds nothing, so Node.js's own snapshots are safe without code. For any other checkpoint, such as a VM snapshot or a serverless warm image, call resetEntropy() from the platform's restore hook. Also call reset() on each generator you made with createSecureRandomGenerator().
  • Start-up cost: nothing is allocated until the first draw. Call resetEntropy({ refill: true }) at boot to pay for the first crypto.getRandomValues call before the first request arrives.
  • Weighted picks: for a loot table or a reward with fixed odds, see Give rewards at the promised rate.