The problem with most A/B frameworks
Bucketing usually relies on a hash of userId mixed with an experiment ID. It's deterministic, but the salt is internal — if you ever change it, every historical bucket assignment becomes unverifiable. Worse, no one outside your team can confirm that a flagged user really was in the treatment group when they saw a bug.
The Provable.io approach
- Each experiment gets a committed
serverHashat launch. - Each user's bucket is derived from
clientSeed = experiment_id + ":" + user_id. - The API returns the same number for the same seed forever — so the assignment is reproducible.
Code
async function bucketFor(experimentId, userId, variants) {
// variants: [{ name: "control", weight: 50 }, { name: "treatment", weight: 50 }]
const total = variants.reduce((s, v) => s + v.weight, 0);
const clientSeed = `${experimentId}:${userId}`;
const url = new URL("https://api.provable.io/api/ints");
url.searchParams.set("clientSeed", clientSeed);
url.searchParams.set("count", "1");
url.searchParams.set("min", "1");
url.searchParams.set("max", String(total));
const res = await fetch(url, {
headers: { "x-api-key": process.env.PROVABLE_KEY }
});
const { outcome, serverHash } = await res.json();
let cumulative = 0;
for (const v of variants) {
cumulative += v.weight;
if (outcome[0] <= cumulative) {
return { variant: v.name, clientSeed, serverHash };
}
}
}
const { variant } = await bucketFor("exp_2026_checkout_v2", "user_8821", [
{ name: "control", weight: 50 },
{ name: "treatment", weight: 50 },
]);
Cache it
The API call is deterministic, so cache the first lookup per user/experiment in your DB. After that, you never hit the API for that user again — but if anyone asks "why was user 8821 in treatment?" you can re-run the call and prove it.
Stickiness across rollouts
Want to expand from 10% → 50% treatment without re-shuffling existing users? Use a fixed weight space (e.g. max=100) and grow the treatment band from the same side. Anyone in the original 10% stays in treatment; new users fall into the expanded band.
// Phase 1: treatment = rolls 1..10
// Phase 2: treatment = rolls 1..50 (everyone in phase 1 still qualifies)
Auditing later
If a compliance review asks "did you really only show this UI to 5% of users?", you can:
- Replay the bucket call for any user ID,
- Show the committed
serverHash, - Point them at /verify for independent confirmation.
Next steps
FAQ
What should I use as the clientSeed for A/B bucketing?
Combine your experiment ID with the user ID, e.g. exp_checkout_v2:user_8821. The HMAC stream is deterministic, so the same seed always returns the same bucket — and unrelated experiments stay independent.
Should I cache the bucket assignment?
Yes — cache locally on first call. The API is deterministic, so re-deriving is always possible, but caching avoids the round trip on every page view. The serverHash you persist with the assignment is what makes it auditable later.
How do I expand a rollout from 10% to 50% without reshuffling existing users?
Use sticky bands: treatment = rolls 1..10 initially, then 1..50 on expansion. Everyone in the first 10% stays in treatment under the expanded band; nobody flips variants.
How can a regulator audit a bucket months later?
Hand them the published serverHash, the clientSeed (experimentId:userId), and the variant weight table. They re-run /api/ints with those inputs and confirm the bucket — no internal salts or legacy hashing code required.