A/B test bucketing decides which variant of an experiment each user sees, deterministically, so the same user always lands in the same variant and only a chosen slice of traffic enters the experiment at all. Teams run experiments to compare a change against the current behavior on live users and read the metrics per variant. For those metrics to mean anything the assignment must be sticky — a user cannot drift between variants between page loads — and independent per experiment — being in the treatment group of one test must not drag the same user into the treatment group of the next. You will build the assigner at the center of this: a factory that reads one experiment config and answers two questions for any user.
type Experiment = {
key: string; // unique experiment id — also the salt for the hash
traffic?: number; // 0..100, default 100 — the share of users let into the experiment
variants: Array<{ key: string; weight: number }>; // relative weights, need not sum to 100
};
function abBucketing(experiment: Experiment): {
assign(userId: string): string | null; // the user's variant key, or null if not in the experiment
isInExperiment(userId: string): boolean; // did the user pass the traffic gate?
};
A 50/50 split that stays sticky per user:
const homepageCta = abBucketing({
key: 'homepage-cta',
variants: [
{ key: 'control', weight: 50 },
{ key: 'treatment', weight: 50 },
],
});
// traffic defaults to 100, so every user is in. u_530 draws 'treatment'
// and keeps it on every call — the assignment is sticky.
homepageCta.assign('u_530'); // 'treatment'
homepageCta.assign('u_530'); // 'treatment' — same answer, always
homepageCta.assign('u_203'); // 'control'
A 20% traffic experiment that excludes most users:
const betaSearch = abBucketing({
key: 'beta-search',
traffic: 20, // only about 20% of users enter the experiment
variants: [
{ key: 'control', weight: 50 },
{ key: 'treatment', weight: 50 },
],
});
betaSearch.isInExperiment('u_042'); // false — outside the 20% gate
betaSearch.assign('u_042'); // null — no variant for an excluded user
betaSearch.assign('u_128'); // null
betaSearch.assign('u_035'); // 'control' — one of the ~20% let in
experiment.key and userId, never Math.random or Date.now. The same user must get the same variant on every call and in every session, or the experiment cannot be measured.assign returns null for any user outside the gate, and isInExperiment is true for exactly the users assign would give a variant. A missing traffic lets the whole audience in; a traffic of 0 lets no one in.experiment.key, a user's bucket in homepage-cta is unrelated to their bucket in checkout-flow. Landing in treatment for one experiment must not correlate with treatment in another.[{ a, weight: 1 }, { b, weight: 3 }] is a 25/75 split. Weights need not sum to 100.You are building the assigner at the heart of an A/B testing system: given one experiment and a user id, it decides whether the user is in the experiment and, if so, which variant they see — the same way every time.
You want to test a new homepage button against the current one. You show the new button to half of a small slice of users, leave everyone else on the old one, and compare conversion. Two things have to hold for the numbers to be trustworthy. Each user must stay on one side for the whole test — if a user bounces between the old and new button on every page load, you are measuring noise, not a change. And this experiment must not entangle with the next one — if the users who saw the new button here are the exact same users who see every other experiment's treatment, your experiments all rise and fall together. Sticky assignment and per-experiment independence are the whole job.
Think of assign as two stages a user falls through. First a traffic gate: a stable number from 0 to 99, derived from the user, decides whether this user is part of the experiment at all — most experiments run on a slice of traffic, not the whole audience. A user who clears the gate reaches the second stage, the variant bucket: another stable number, taken over the total of the variant weights, picks which variant window they land in. Both numbers come from hashing the user id, so they never change for a given user.
The obvious version reaches for Math.random to pick a variant:
function abBucketing(experiment) {
return {
isInExperiment() {
return Math.random() * 100 < (experiment.traffic ?? 100); // fresh dice each call
},
assign() {
const vs = experiment.variants;
return vs[Math.floor(Math.random() * vs.length)].key; // random variant each call
},
};
}
The proportions look right in aggregate — over enough users, roughly half of a 50/50 split returns treatment. But it is wrong per user. Math.random re-rolls on every call, so one user sees control now and treatment a second later, and a reload flips the experience. An experiment is supposed to hold each user on one variant while you measure, so this makes the results meaningless and the UI flicker.
The fix is to derive the bucket from the user instead of from dice — hash the userId so it never changes:
const bucket = stableHash(userId) % 100; // sticky now, but subtly broken
That is sticky, but it hashes the userId alone. Every experiment then buckets the same user the same way, so whoever lands in treatment for homepage-cta lands in treatment for checkout-flow too. The experiments are now correlated — a win in one looks like a win in the other — which quietly corrupts every result. The missing ingredient is the salt.
Hash the user, and salt the hash with the experiment key so each experiment buckets the user independently. The traffic gate and the variant draw use different salts, so the two decisions never leak into each other.
// A small, deterministic, non-negative string hash (FNV-1a, 32-bit).
// The same string ALWAYS produces the same number — that is what makes an
// assignment sticky. Never Math.random() or Date.now() here.
function stableHash(str) {
let hash = 2166136261; // FNV offset basis
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i); // fold in the next character
hash = Math.imul(hash, 16777619); // multiply by the FNV prime, in 32-bit math
}
return hash >>> 0; // reinterpret the signed 32-bit result as non-negative
}
function abBucketing(experiment) {
// Read the config once, when the assigner is built. A missing traffic means
// "no gate" — the whole audience is eligible.
const traffic = experiment.traffic == null ? 100 : experiment.traffic;
const variants = experiment.variants;
const totalWeight = variants.reduce((sum, v) => sum + v.weight, 0);
// The traffic gate: is this user allowed into the experiment at all? Its own
// salt (':traffic:') keeps the gate independent of the variant draw.
function isInExperiment(userId) {
if (traffic >= 100) return true;
if (traffic <= 0) return false;
const gate = stableHash(experiment.key + ':traffic:' + userId) % 100;
return gate < traffic;
}
function assign(userId) {
// A user outside the gate is not in the experiment — no variant.
if (!isInExperiment(userId)) return null;
// Bucket across the TOTAL weight (not 100), salted with the experiment key
// so this user's variant is independent of every other experiment.
const bucket = stableHash(experiment.key + ':' + userId) % totalWeight;
// Walk the cumulative weight windows: the first variant owns [0, w0), the
// next owns [w0, w0 + w1), and so on. Return the window the bucket lands in.
let cursor = 0;
for (const v of variants) {
cursor += v.weight;
if (bucket < cursor) return v.key;
}
return variants[variants.length - 1].key; // safety net for rounding
}
return { assign, isInExperiment };
}
module.exports = { abBucketing };
Two things change from the naive version. First, the dice become a hash: stableHash folds the string into one fixed 32-bit number, so the same (key, userId) pair always maps to the same bucket — that is stickiness. Math.imul keeps the FNV multiply in true 32-bit integer math (a plain * overflows into floating point and drops the low bits), and >>> 0 reinterprets the final value as a non-negative integer. Second, the salt carries experiment.key, so a user's bucket is scoped to this experiment: homepage-cta:u_530 and checkout-flow:u_530 are different strings and hash to unrelated buckets. The two returned functions close over experiment, so the config is read once and every call reads the same source of truth.
Take an experiment pricing-page at 60% traffic with three weighted variants — control 3, treatment 3, holdback 2 — and evaluate assign('u_450'):
traffic is 60, not 100 or 0, so we hash: stableHash('pricing-page:traffic:u_450') % 100 is 23. Since 23 < 60, u_450 clears the gate and is in the experiment. (isInExperiment('u_450') runs exactly this and returns true.)totalWeight = 8, so we take the bucket over 8: stableHash('pricing-page:u_450') % 8 is 4. Now walk the cumulative windows. After control the cursor is 3; is 4 < 3? No. After treatment the cursor is 6; is 4 < 6? Yes — so u_450 gets treatment.Call assign('u_450') again and you get the same 23 and the same 4: the assignment is sticky.
Now contrast a user the gate excludes. For u_042, stableHash('pricing-page:traffic:u_042') % 100 is 92, and 92 < 60 is false — so the gate rejects them. assign('u_042') returns null and isInExperiment('u_042') returns false, never reaching the variant bucket.
Math.random — the same user flips variants between calls, so the experiment cannot be measured and the UI flickers. Fix: hash key + ':' + userId and map the bucket to a variant.userId alone — every experiment buckets the user identically, so their variants correlate across experiments and one experiment's result contaminates the next. Fix: salt the hash with experiment.key.:traffic: for the gate and a plain : for the variant.% 100 instead of % totalWeight — when the weights do not sum to 100, the windows do not cover the bucket range and users fall past the last window. Fix: take the bucket modulo the summed weight.null from assign whenever the gate fails.traffic from 5 to 50 to 100 across a launch. Because the gate is a stable bucket, everyone already inside stays inside as you widen it, so no user is ever kicked out mid-experiment.accountId instead of userId so an entire company ramps in together rather than user by user.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A/B test bucketing decides which variant of an experiment each user sees, deterministically, so the same user always lands in the same variant and only a chosen slice of traffic enters the experiment at all. Teams run experiments to compare a change against the current behavior on live users and read the metrics per variant. For those metrics to mean anything the assignment must be sticky — a user cannot drift between variants between page loads — and independent per experiment — being in the treatment group of one test must not drag the same user into the treatment group of the next. You will build the assigner at the center of this: a factory that reads one experiment config and answers two questions for any user.
type Experiment = {
key: string; // unique experiment id — also the salt for the hash
traffic?: number; // 0..100, default 100 — the share of users let into the experiment
variants: Array<{ key: string; weight: number }>; // relative weights, need not sum to 100
};
function abBucketing(experiment: Experiment): {
assign(userId: string): string | null; // the user's variant key, or null if not in the experiment
isInExperiment(userId: string): boolean; // did the user pass the traffic gate?
};
A 50/50 split that stays sticky per user:
const homepageCta = abBucketing({
key: 'homepage-cta',
variants: [
{ key: 'control', weight: 50 },
{ key: 'treatment', weight: 50 },
],
});
// traffic defaults to 100, so every user is in. u_530 draws 'treatment'
// and keeps it on every call — the assignment is sticky.
homepageCta.assign('u_530'); // 'treatment'
homepageCta.assign('u_530'); // 'treatment' — same answer, always
homepageCta.assign('u_203'); // 'control'
A 20% traffic experiment that excludes most users:
const betaSearch = abBucketing({
key: 'beta-search',
traffic: 20, // only about 20% of users enter the experiment
variants: [
{ key: 'control', weight: 50 },
{ key: 'treatment', weight: 50 },
],
});
betaSearch.isInExperiment('u_042'); // false — outside the 20% gate
betaSearch.assign('u_042'); // null — no variant for an excluded user
betaSearch.assign('u_128'); // null
betaSearch.assign('u_035'); // 'control' — one of the ~20% let in
experiment.key and userId, never Math.random or Date.now. The same user must get the same variant on every call and in every session, or the experiment cannot be measured.assign returns null for any user outside the gate, and isInExperiment is true for exactly the users assign would give a variant. A missing traffic lets the whole audience in; a traffic of 0 lets no one in.experiment.key, a user's bucket in homepage-cta is unrelated to their bucket in checkout-flow. Landing in treatment for one experiment must not correlate with treatment in another.[{ a, weight: 1 }, { b, weight: 3 }] is a 25/75 split. Weights need not sum to 100.You are building the assigner at the heart of an A/B testing system: given one experiment and a user id, it decides whether the user is in the experiment and, if so, which variant they see — the same way every time.
You want to test a new homepage button against the current one. You show the new button to half of a small slice of users, leave everyone else on the old one, and compare conversion. Two things have to hold for the numbers to be trustworthy. Each user must stay on one side for the whole test — if a user bounces between the old and new button on every page load, you are measuring noise, not a change. And this experiment must not entangle with the next one — if the users who saw the new button here are the exact same users who see every other experiment's treatment, your experiments all rise and fall together. Sticky assignment and per-experiment independence are the whole job.
Think of assign as two stages a user falls through. First a traffic gate: a stable number from 0 to 99, derived from the user, decides whether this user is part of the experiment at all — most experiments run on a slice of traffic, not the whole audience. A user who clears the gate reaches the second stage, the variant bucket: another stable number, taken over the total of the variant weights, picks which variant window they land in. Both numbers come from hashing the user id, so they never change for a given user.
The obvious version reaches for Math.random to pick a variant:
function abBucketing(experiment) {
return {
isInExperiment() {
return Math.random() * 100 < (experiment.traffic ?? 100); // fresh dice each call
},
assign() {
const vs = experiment.variants;
return vs[Math.floor(Math.random() * vs.length)].key; // random variant each call
},
};
}
The proportions look right in aggregate — over enough users, roughly half of a 50/50 split returns treatment. But it is wrong per user. Math.random re-rolls on every call, so one user sees control now and treatment a second later, and a reload flips the experience. An experiment is supposed to hold each user on one variant while you measure, so this makes the results meaningless and the UI flicker.
The fix is to derive the bucket from the user instead of from dice — hash the userId so it never changes:
const bucket = stableHash(userId) % 100; // sticky now, but subtly broken
That is sticky, but it hashes the userId alone. Every experiment then buckets the same user the same way, so whoever lands in treatment for homepage-cta lands in treatment for checkout-flow too. The experiments are now correlated — a win in one looks like a win in the other — which quietly corrupts every result. The missing ingredient is the salt.
Hash the user, and salt the hash with the experiment key so each experiment buckets the user independently. The traffic gate and the variant draw use different salts, so the two decisions never leak into each other.
// A small, deterministic, non-negative string hash (FNV-1a, 32-bit).
// The same string ALWAYS produces the same number — that is what makes an
// assignment sticky. Never Math.random() or Date.now() here.
function stableHash(str) {
let hash = 2166136261; // FNV offset basis
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i); // fold in the next character
hash = Math.imul(hash, 16777619); // multiply by the FNV prime, in 32-bit math
}
return hash >>> 0; // reinterpret the signed 32-bit result as non-negative
}
function abBucketing(experiment) {
// Read the config once, when the assigner is built. A missing traffic means
// "no gate" — the whole audience is eligible.
const traffic = experiment.traffic == null ? 100 : experiment.traffic;
const variants = experiment.variants;
const totalWeight = variants.reduce((sum, v) => sum + v.weight, 0);
// The traffic gate: is this user allowed into the experiment at all? Its own
// salt (':traffic:') keeps the gate independent of the variant draw.
function isInExperiment(userId) {
if (traffic >= 100) return true;
if (traffic <= 0) return false;
const gate = stableHash(experiment.key + ':traffic:' + userId) % 100;
return gate < traffic;
}
function assign(userId) {
// A user outside the gate is not in the experiment — no variant.
if (!isInExperiment(userId)) return null;
// Bucket across the TOTAL weight (not 100), salted with the experiment key
// so this user's variant is independent of every other experiment.
const bucket = stableHash(experiment.key + ':' + userId) % totalWeight;
// Walk the cumulative weight windows: the first variant owns [0, w0), the
// next owns [w0, w0 + w1), and so on. Return the window the bucket lands in.
let cursor = 0;
for (const v of variants) {
cursor += v.weight;
if (bucket < cursor) return v.key;
}
return variants[variants.length - 1].key; // safety net for rounding
}
return { assign, isInExperiment };
}
module.exports = { abBucketing };
Two things change from the naive version. First, the dice become a hash: stableHash folds the string into one fixed 32-bit number, so the same (key, userId) pair always maps to the same bucket — that is stickiness. Math.imul keeps the FNV multiply in true 32-bit integer math (a plain * overflows into floating point and drops the low bits), and >>> 0 reinterprets the final value as a non-negative integer. Second, the salt carries experiment.key, so a user's bucket is scoped to this experiment: homepage-cta:u_530 and checkout-flow:u_530 are different strings and hash to unrelated buckets. The two returned functions close over experiment, so the config is read once and every call reads the same source of truth.
Take an experiment pricing-page at 60% traffic with three weighted variants — control 3, treatment 3, holdback 2 — and evaluate assign('u_450'):
traffic is 60, not 100 or 0, so we hash: stableHash('pricing-page:traffic:u_450') % 100 is 23. Since 23 < 60, u_450 clears the gate and is in the experiment. (isInExperiment('u_450') runs exactly this and returns true.)totalWeight = 8, so we take the bucket over 8: stableHash('pricing-page:u_450') % 8 is 4. Now walk the cumulative windows. After control the cursor is 3; is 4 < 3? No. After treatment the cursor is 6; is 4 < 6? Yes — so u_450 gets treatment.Call assign('u_450') again and you get the same 23 and the same 4: the assignment is sticky.
Now contrast a user the gate excludes. For u_042, stableHash('pricing-page:traffic:u_042') % 100 is 92, and 92 < 60 is false — so the gate rejects them. assign('u_042') returns null and isInExperiment('u_042') returns false, never reaching the variant bucket.
Math.random — the same user flips variants between calls, so the experiment cannot be measured and the UI flickers. Fix: hash key + ':' + userId and map the bucket to a variant.userId alone — every experiment buckets the user identically, so their variants correlate across experiments and one experiment's result contaminates the next. Fix: salt the hash with experiment.key.:traffic: for the gate and a plain : for the variant.% 100 instead of % totalWeight — when the weights do not sum to 100, the windows do not cover the bucket range and users fall past the last window. Fix: take the bucket modulo the summed weight.null from assign whenever the gate fails.traffic from 5 to 50 to 100 across a launch. Because the gate is a stable bucket, everyone already inside stays inside as you widen it, so no user is ever kicked out mid-experiment.accountId instead of userId so an entire company ramps in together rather than user by user.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.