Deterministic bucketing: how a user gets a variant
Deterministic bucketing assigns a user to a variant by hashing their user id, instead of looking the assignment up in a database. In dif the bucket is the first four bytes of SHA-256(salt || user_id), read as a big-endian unsigned 32-bit integer, modulo 10,000. Nothing is stored. The same user id and the same flag produce the same number every time, on every device, in every process.
The alternative is an assignment table. A user hits your app, the flag service checks whether it has seen them before, writes a row if it has not, and returns the variant. That table is a network call on the render path, a cache to invalidate, and a row that can disagree with itself across two regions. It is also the reason a user sometimes sees the control variant on one page load and the treatment on the next.
This post is about how that number is computed, what it guarantees, and what it costs you.
Key Takeaways
- A bucket in dif is
SHA-256(salt || user_id), first four bytes as a big-endian u32, mod 10,000, which gives a number in[0, 9999]. The variant is picked by walking declared weights until the cumulative total crosses the bucket.- The hash reads four bytes rather than two because
2^16 % 10,000 = 5,536, which would hand buckets 0 to 5,535 an extra preimage each and turn a nominal 50/50 experiment into roughly 53.4/46.6. At 32 bits the residual bias is about 1.7e-6.- Each flag derives its own 16-byte salt from
SHA-256("dif.sh/v1" || experiment_id), so useru_1sits at bucket 7390 oncheckout-cta-v2and 4005 onpricing-headline.- Ramping a flag from 10% to 25% repaints buckets at the boundary and never moves the users already inside, so nobody has the feature taken away mid-rollout.
- The cost is a stable user id. Logged-out traffic depends on the
dif_uidcookie, and a user who clears it gets a new bucket.
What deterministic bucketing is
Deterministic bucketing means the variant a user gets is a pure function of their id and the flag, computed on the spot rather than read from storage.
Picture a stadium with 10,000 seats, one per bucket. Seats are painted by weight: a 50/50 experiment paints seats 0 to 4,999 control and 5,000 to 9,999 variant_a. A flag sitting at 10% paints 0 to 8,999 off and 9,000 to 9,999 on. Every user is sent to a seat by running their id through the same hash, and the hash always sends them to the same seat.
For an engineer, the consequence is that evaluating a flag is arithmetic. The generated client from dif build carries the salt and the weights, so the JS SDK resolves a variant without calling anything. For a PM, the consequence is that a user’s experience does not change under them halfway through a session, which is what makes the metric attributable to the variant.
The math: SHA-256, four bytes, mod 10,000
The whole bucket function, from packages/sdk/src/bucket.ts:
export function bucket(saltHex: string, userId: string): number {
const salt = hexToBytes(saltHex); // 16 raw bytes
const user = TE.encode(userId); // UTF-8
const buf = new Uint8Array(salt.length + user.length);
buf.set(salt);
buf.set(user, salt.length);
const digest = sha256(buf);
const quad =
((digest[0]! << 24) | (digest[1]! << 16) | (digest[2]! << 8) | digest[3]!) >>> 0;
return quad % 10_000;
}
The salt is concatenated in front of the user id, both as raw bytes, and the whole buffer goes through SHA-256. The first four bytes of the digest become an unsigned 32-bit integer, and % 10_000 lands it in [0, 9999].
Picking the variant from that number is a second short function. It walks the variants in the order they are declared in the flag file, accumulating weight * 100, and returns the first variant whose running total crosses the bucket:
let cumulative = 0;
for (const variant of variants) {
cumulative += (weights[variant] ?? 0) * 100;
if (bucketValue < cumulative) return variant;
}
return null;
Weights are percentages, so weight * 100 converts them to bucket counts. A control at 50 covers 5,000 buckets. dif validate rejects a file whose weights do not total 100, which is why the null branch is a guard and not a runtime path.
Four bytes, because two would skew a 50/50 test
Modulo folds a large range onto a small one, and it only folds evenly when the small range divides the large one. It does not here. With a 16-bit source there are 65,536 possible values mapped onto 10,000 buckets, and 65,536 % 10,000 = 5,536. Buckets 0 through 5,535 each get seven source values; buckets 5,536 through 9,999 each get six. A nominal 50/50 experiment would actually run at about 53.4/46.6, and you would spend a week wondering why control keeps winning.
With a 32-bit source, 4,294,967,296 % 10,000 = 7,296, spread across 10,000 buckets that each already hold about 429,496 values. The residual bias works out to roughly 1.7e-6, which is far below the noise floor of any experiment you will run.
Each flag has its own salt
The salt is what keeps flags from correlating with each other. dif derives one per flag rather than asking you to pick it: the salt is the first 16 bytes of SHA-256("dif.sh/v1" || experiment_id), hex encoded, and dif build embeds it in the generated client.
Because the id feeds the salt, two flags give the same user two unrelated buckets. That is checked against a fixture, not asserted. From crates/dif-core/tests/fixtures/bucket_tests.json:
{ "experiment_id": "checkout-cta-v2", "user_id": "u_1", "bucket": 7390 },
{ "experiment_id": "checkout-cta-v2", "user_id": "u_8131", "bucket": 9709 },
{ "experiment_id": "pricing-headline", "user_id": "u_1", "bucket": 4005 },
{ "experiment_id": "pricing-headline", "user_id": "u_8131", "bucket": 2990 }
User u_1 sits at 7390 on one flag and 4005 on the other. Being in the treatment group for a checkout test tells you nothing about that user’s group in a pricing test, so two concurrent experiments do not quietly measure the same cohort twice.
The same fixture is the contract between implementations. The bucketing math exists twice, in the Rust core that powers the CLI and in the TypeScript SDK, and both run every case in the file. If the two ever disagree by a single bucket, CI fails on both sides. That matters when the server renders a page and the browser hydrates it, because both have to reach the same variant independently.
Ramping repaints buckets, it does not reshuffle them
Widening a rollout is an edit to one number in the flag file, reviewed in a pull request like any other change:
variants:
- id: "off"
weight: 75
- id: "on"
weight: 25
Going from 10% to 25% moves the boundary from bucket 9,000 down to 7,500. Users at 9,000 and above were already seeing on and still are. Users between 7,500 and 8,999 join them. Nobody who had the feature loses it, because the seat assignment never moved and only the paint changed.
This is why the enabled variant is declared last in the file. Cumulative weights accumulate in declared order, so off occupies the low buckets and on grows downward into them as you ramp.
Trace a bucketing decision with dif qa
You do not have to reason about any of this from memory. dif qa runs the same resolution the SDK runs and prints what a specific user gets:
$ dif qa --user u_8131
trace u_8131:
• checkout-cta-v2 → variant_a (bucket 9709)
• pricing-headline → control (bucket 2990)
preview: http://localhost:3000?_dif=…
The trace names the bucket, so an unexpected variant is a number you can check rather than a guess. It also prints the outcome when a user is not assigned at all: audience miss when they fail the audience predicate, or exclusion loser (winner: <id>) when a sibling experiment in the same exclusion group claimed them first. Those users show the first declared variant and record nothing, which keeps unassigned traffic out of the results. The CLI reference covers the --force and --attr flags for tracing a specific audience.
What deterministic bucketing costs you
It needs a stable user id, and that is the whole bill.
For logged-in traffic this is free, since you already have an id. For logged-out traffic dif falls back to a dif_uid cookie holding a random UUID. A user who clears cookies, opens a private window, or arrives on a second device before signing in is a new id and therefore a new bucket. Any hash-based assignment has this property, including LaunchDarkly’s and Optimizely’s. An assignment database does not fix it either, because the database is keyed on the same id you just lost.
Two smaller edges are worth knowing before you hit them:
- Shrinking a rollout takes the feature away. Ramps are additive going up and subtractive coming down. Moving
onfrom 25% back to 10% pulls buckets 7,500 to 8,999 back tooff, and those users lose the feature. - Variant order in the file is load-bearing. Cumulative weights walk the declared order, so swapping
controlandvariant_ain the frontmatter reassigns everyone even though the weights are unchanged. Change weights during an experiment, not order. The .md format reference documents the frontmatter fields.
What it does not cost you is a service. There is no assignment store to run and shard, and no evaluation call that can time out on the render path. Martin Fowler’s write-up on feature toggles makes the general point that the runtime cost of a toggle decides where teams are willing to put one. One hash over a salt and a short user id is cheap enough to put anywhere.
FAQ
How does deterministic bucketing work? A hash of the user id decides the variant. dif concatenates the flag’s 16-byte salt with the UTF-8 user id, runs SHA-256 over the buffer, reads the first four bytes as a big-endian u32, and takes it mod 10,000. The resulting bucket is matched against cumulative variant weights. Nothing is written down, so the same inputs recompute the same answer every time.
Will a user see the same variant on every visit? Yes, as long as the user id is the same. The bucket is recomputed from the id, not read from storage, so it survives page loads, new devices, cache clears, and redeploys. If the id changes, such as an anonymous visitor who clears the dif_uid cookie, the bucket changes with it.
Do feature flags need a database to assign variants? No. An assignment table is one way to make variants sticky, but hashing the user id gets stickiness without storing anything. dif evaluates flags with no network call, which is also why feature flags in React render the right variant on the server and the client without a flash of the wrong one.
What happens to users already in an experiment when I ramp it? They stay where they are. Widening a rollout lowers the boundary bucket and adds users below it. Because assignment is positional and never reshuffled, existing participants keep the variant they had.
Can two experiments give the same user correlated variants? Not by accident. Each flag derives its own salt from its id, so the buckets are unrelated: u_1 lands at 7390 on checkout-cta-v2 and 4005 on pricing-headline. When two experiments run on the same surface and you need a guarantee that a user is in at most one, tag both with an exclusion_group and dif validate enforces it.
Is SHA-256 fast enough to run on every render? Yes. The hash covers a 16-byte salt plus a short user id, which is a single compression block. Browsers expose the primitive through SubtleCrypto, but that API is async, so dif ships its own zero-dependency implementation to keep assignment synchronous. The bucket is recomputed on each call rather than cached, which is what makes it consistent across processes. The exposure event is the part that gets deduped, firing once per flag and user per session.
Where this leaves you
Deterministic bucketing turns variant assignment into arithmetic. The bucket is SHA-256(salt || user_id), four bytes, mod 10,000, matched against cumulative weights. Nothing is stored, so there is no assignment database to operate and no lookup on the render path. Per-flag salts keep concurrent experiments from measuring the same cohort twice, and a shared fixture keeps the Rust and TypeScript implementations from drifting apart by even one bucket. The price is that you need a stable user id, and anonymous traffic is only as stable as its cookie.
To see the buckets in your own repo, install the CLI and trace a user:
npm install -g @dif.sh/cli
dif init
dif qa --user u_8131
That prints the variant and the bucket for every active flag, with no account and no dashboard. Running an A/B test as files covers what goes in the flag file once the assignment makes sense.