Why you'd switch — and why you might not

Math.random() is fast, free, and synchronous. Its outcome is also impossible for anyone outside your server to audit. crypto.randomInt upgrades you to a cryptographic PRNG, but doesn't change that audit story at all — neither call leaves a paper trail.

Provable.io gives you a per-outcome serverHash a player or auditor can later check against the live seed state or a daily Merkle root. That's the only thing it gives you that the standard library doesn't. If "I trust me" is a valid threat model for your app (a single-player toy, a coin-flip prank), don't migrate. If your users have money or pride on the line, this guide is for you.

The three patterns you'll port

1. A range int

// Before
const sides = 6;
const roll = 1 + Math.floor(Math.random() * sides);

// After
const res = await fetch(
    `https://api.provable.io/api/ints?clientSeed=${encodeURIComponent(playerId)}&count=1&min=1&max=${sides}`,
    { headers: { "x-api-key": process.env.PROVABLE_API_KEY } },
);
const { outcome, serverHash, shortId, permalink } = await res.json();
const roll = outcome[0];

Persist serverHash and permalink alongside the roll. That's the part your player can audit. Try the underlying call live:

curl "https://api.provable.io/api/ints?clientSeed=player_42&count=1&min=1&max=6"

2. A weighted pick

// Before — homegrown weighted pick
function weightedPick(items, weights) {
    const total = weights.reduce((s, w) => s + w, 0);
    let r = Math.random() * total;
    for (let i = 0; i < items.length; i++) {
        r -= weights[i];
        if (r < 0) return items[i];
    }
}

// After — one call to /api/pick
const url = new URL("https://api.provable.io/api/pick");
url.searchParams.set("clientSeed", `loot:${dropId}`);
url.searchParams.set("items", JSON.stringify(items));
url.searchParams.set("weights", JSON.stringify(weights));
const { outcome, index, serverHash } = await (await fetch(url, {
    headers: { "x-api-key": process.env.PROVABLE_API_KEY },
})).json();

The server applies the same mathematically-correct weighted selection — no rounding bugs, no modulo-bias surprises — and it's verifiable. Try it:

curl "https://api.provable.io/api/pick?clientSeed=loot:drop-42&items=%5B%22common%22%2C%22rare%22%2C%22legendary%22%5D&weights=%5B70%2C25%2C5%5D"

3. A shuffle

// Before — Fisher-Yates with Math.random
function shuffle(deck) {
    for (let i = deck.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [deck[i], deck[j]] = [deck[j], deck[i]];
    }
    return deck;
}

// After — /api/shuffle returns the shuffled array directly
const url = new URL("https://api.provable.io/api/shuffle");
url.searchParams.set("clientSeed", `table-${tableId}:hand-${handNumber}`);
url.searchParams.set("items", JSON.stringify(deck));
const { outcome: shuffled, serverHash } = await (await fetch(url, {
    headers: { "x-api-key": process.env.PROVABLE_API_KEY },
})).json();

One round trip, an entire shuffled deck, one serverHash covering the whole permutation. Shuffle a tiny deck right now:

curl "https://api.provable.io/api/shuffle?clientSeed=table-1:hand-1&items=%5B%22A%E2%99%A0%22%2C%22K%E2%99%A0%22%2C%22Q%E2%99%A0%22%2C%22J%E2%99%A0%22%2C%2210%E2%99%A0%22%5D"

Behavioral differences to plan for

Math.random() / crypto.randomIntProvable.io
Latency~0 (in-process)~50-200ms (network)
Sync / asyncSyncAsync — every call is HTTP
ReproducibleNoYes, given the inputs
Verifiable by othersNoYes via /api/verifyServerHash
Failure modesNoneHTTP errors, rate limits, quota
QuotaInfinitePer-account daily limit

The async + latency item is the big one. Don't call Provable.io inside a render hot path; do it on the server, when the round transitions, and stream / push the result to the client.

Phase the change in behind a feature flag

Big-bang flips are scary. A safer rollout:

  1. Extract a single seam. Every Math.random() use site routes through a thin wrapper — random.rangeInt(min, max, ctx), random.pick(items, weights, ctx), random.shuffle(items, ctx). The ctx carries the logical id you'd use as a clientSeed (player id, round id, table id).
  2. Two implementations. The legacy one keeps calling Math.random(). The new one calls Provable.io.
  3. Feature-flag the switch per code path. Start with the lowest-stakes path (cosmetic shuffles, dev tools), then bigger ones, then the headline use case.
  4. Shadow it before flipping. When the flag is off, still fire the Provable.io call in the background and log latency / error rate. This catches quota, network, and rate-limit issues before they hit users.
  5. Use test-mode keys in CI. See Test mode vs live mode — your snapshot tests stay deterministic.

Reference wrapper

// random.js
import { randomInt } from "node:crypto";
const USE_PROVABLE = process.env.RNG_BACKEND === "provable";
const KEY = process.env.PROVABLE_API_KEY;

export async function rangeInt(min, max, ctx) {
    if (!USE_PROVABLE) return randomInt(min, max + 1);
    const url = new URL("https://api.provable.io/api/ints");
    url.searchParams.set("clientSeed", ctx);
    url.searchParams.set("count", "1");
    url.searchParams.set("min", min);
    url.searchParams.set("max", max);
    const res = await fetch(url, { headers: { "x-api-key": KEY } });
    if (!res.ok) throw new Error(`provable: http ${res.status}`);
    const { outcome, serverHash, permalink } = await res.json();
    return { value: outcome[0], serverHash, permalink };
}

The wrapper's return shape changes between the two backends — that's the point. Code paths that switched have to handle the audit trail; code paths that haven't keep the simple primitive return type. Migrate intentionally.

Things you'll trip over once

Next steps