Rollouts & bucketing
How the percentage actually works — sticky hashing, deterministic assignment, multi-arm rollouts, salt, and how to re-shuffle.
A rollout is the percentage of eligible traffic that gets the gate's true answer. The
implementation is deterministic hashing — same user, same gate, same answer, on every server, in
every SDK, forever (until you bump the salt). This page is the precise model of how that works.
The hash
For a call gate(name, ctx):
bucket = hash(salt || ":" || bucketBy(ctx)) mod 10_000
return bucket < rolloutPct * 100saltis per-gate (name + ":" + saltVersion). Defaults to the gate name and"v1".bucketBy(ctx)isctx.userIdby default. Override per call with{ bucketBy: "accountId" }.- The modulus is
10_000, so rollout precision is 0.01% (basis points). The dashboard shows 0–100; internally the value is0..10000. - The hash is
murmur3_32. Cheap, well-distributed, identical across SDKs (server, browser, Python, Ruby, Go).
Why it's sticky
The hash inputs are stable: the salt doesn't change unless you change it, and userId is whatever
identity you pass. The output is deterministic. So once a user lands in the "is in" half of a 50%
rollout, they stay there as long as the rollout is at or above their bucket.
That's the whole stickiness mechanism. No assignment table, no cookie, no server-side memory.
Ramping up keeps existing users in
Bumping the rollout from 5% → 25% does not re-bucket anyone. It changes the cutoff from 500 to
2500, which means buckets [0..2499] are now true. Every user who was inside the old [0..499]
range stays inside. The expansion is additive — new buckets become true; existing true buckets
stay true.
Same with ramping down. 25% → 5% kicks the users in [500..2499] back to false, but the users
in [0..499] are unaffected.
This is the property that makes progressive rollout safe: you can ramp confidently because no one is randomly shuffled with each change.
The bucketing key
bucketBy controls what stickiness means.
| Value | Stickiness scope | Use when |
|---|---|---|
userId | Per signed-in user, across devices and sessions | Default. Almost always what you want. |
accountId | Per organisation — all teammates see the same thing | B2B: rollout by company, not by seat. |
| Cookie ID | Per browser — survives across sessions on one device | Logged-out users on a marketing page. |
requestId | Per request — re-buckets every call | Don't. This is what bad bucketing looks like. |
Pick once per gate and stick with it. Switching bucketBy mid-rollout is the same as changing
the salt — every user re-buckets, your "5% who saw v2" is now a completely different 5%.
// B2B cohort consistency — all members of an account see the same variant
await gate("admin-redesign", { userId, accountId }, { bucketBy: "accountId" });Salt — the re-shuffle lever
The salt is what makes two 50% rollouts not serve the same 50% of users. Each gate has its own salt by default, so:
gate-aat 50% returnstruefor users in buckets[0..4999]ofhash("gate-a:v1" + userId).gate-bat 50% returnstruefor users in buckets[0..4999]ofhash("gate-b:v1" + userId).
Different inputs to the hash → uncorrelated bucket distributions → independent rollouts.
You almost never bump the salt manually. The two cases where you would:
-
You ran a 5% rollout, killed it, want to try the same feature on a different 5%. Bump the salt to
v2. The new hash gives a different population. The original[0..499]users are no longer in the new bucket[0..499]. -
You're running back-to-back experiments and don't want carryover. Same logic — bump the salt so the second experiment's 50/50 split is independent of the first one's.
shipeasy flags update checkout-v2 --salt v2That's it. The whole rollout reshuffles. Use sparingly — every salt bump invalidates any analysis that relied on "the same set of users."
Multi-arm rollouts
A gate has one rollout percentage. For "70% A, 20% B, 10% C," use an experiment with three variants and a universe split, not a gate. See Experiments quickstart.
If you genuinely need multi-arm and an experiment is overkill, a config with three string values keyed by user-bucket modulo is a smaller pattern — but at that point you're reinventing experiments. Default to the experiment.
Bucket precision
The rollout field accepts integers from 0 to 100 in the UI (and 0..10000 basis points in the
API/CLI). Sub-percent resolution is sometimes useful:
# 0.5% — useful for cautious initial exposure on a high-traffic feature
shipeasy flags update payments-v2 --rollout-bps 50Internally, every rollout is a basis point. The UI rounds for display.
Sticky bucketing across SDKs
Every Shipeasy SDK uses the same hash function, same salt format, same modulus. Server-side
evaluation (Node, Go, Ruby, Python) and client-side (browser, React Native) produce identical
bucket assignments for the same userId + salt.
That's how server-rendered pages can pre-evaluate a flag, ship the result to the client, and the client SDK re-evaluates to the same answer. No mismatch, no hydration flicker. See Edge cases / SSR flicker for the SSR pattern.
Anonymous and logged-out users
If you don't have a userId, the SDK still needs something deterministic. Two options:
-
Cookie-stored anonymous ID. Generate once, persist in a
httpOnlycookie, pass asuserId. Stickiness survives across sessions on one device. -
IP-derived ID. Only as a last resort — IPs change behind NAT, mobile carriers, VPNs, and you'll see the same user re-bucket. Use only when you have no other identifier and you're okay with that.
Don't fall back to Math.random(). The user re-buckets on every call, the variant flickers, and
event-stream analysis becomes meaningless.
Debugging "why did this user get the rollout?"
The CLI's eval command mirrors the SDK exactly and prints the bucket:
shipeasy flags eval checkout-v2 \
--ctx '{"userId":"u_4f2a","plan":"pro","country":"US"}'
# enabled: true
# killswitch: false
# override: none
# rules: matched (plan=pro AND country=US)
# bucket: 3214
# rolloutPct: 2500 (25%)
# decision: false (bucket >= cutoff)If decision doesn't match what the user reports seeing, something is different about the ctx
the SDK actually saw. Usually a missing or differently-named attribute.
Targeting rules
Decide who is eligible for a gate. Predicates against user, account, request, or any custom attribute — ANDed, evaluated locally, zero network cost.
Overrides
Force a gate on or off for specific user IDs — for QA, dogfood, demos, and customer-specific repros. Bypasses targeting and rollout.