Percentage rollouts: how feature flags keep a user in the same group
How a feature flag turns a feature on for exactly 10% of users, and keeps each user in their group as you go to 50% and 100%. With TypeScript code.
A percentage rollout turns a feature on for a share of your users, say 10%, and lets you raise that share step by step. It sounds like Math.random() < 0.1, but random numbers break it: a user would see the feature on one request and lose it on the next.
What you need is deterministic bucketing: the same user always gets the same answer for the same flag, and raising the percentage only adds users, never removes them.
Hash, then compare
The standard technique is:
- Build a string from the flag key and the user's ID, like
new-checkout:user-42. - Hash it to a number.
- Map that number to a bucket from 0 to 99 (or 0 to 9,999 for finer steps).
- The user is in the rollout if their bucket is below the percentage.
function fnv1a32(input: string): number {
let hash = 0x811c9dc5
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return hash >>> 0
}
export function bucket(flagKey: string, userId: string): number {
return fnv1a32(`${flagKey}:${userId}`) % 10_000
}
export function inRollout(flagKey: string, userId: string, percentage: number): boolean {
return bucket(flagKey, userId) < percentage * 100
}
inRollout('new-checkout', 'user-42', 10) returns the same value on every server, every request and every restart, with no database lookup.
Why include the flag key in the hash
If you hashed only the user ID, the same 10% of users would be first in line for every feature you ever roll out. That small group would carry all the risk, and your measurements would be skewed by whoever they happen to be. Adding the flag key gives every flag its own independent split.
Sometimes you want the opposite: two flags that must go to the same users (a new API and the UI that uses it). Use a shared salt for both flags instead of their keys.
Why raising the percentage is safe
A user in bucket 734 is outside a 5% rollout (buckets 0–499) and inside a 10% one (0–999). Raising the percentage only extends the range, so everyone who already had the feature keeps it. Lowering it removes users from the top of the range, which is exactly what you want when something goes wrong.
Pick the right ID
The value you hash decides what "the same user" means:
- User ID for signed-in users. The experience follows them across devices.
- An anonymous ID stored in a cookie or on the device, for visitors who have not signed in. When they sign in, they may switch groups unless you link the two IDs.
- Account or organization ID for B2B products, so that everyone in the same company sees the same thing. Half a team seeing a new screen and half not makes support conversations confusing.
The hash must be fast and well distributed
You do not need a cryptographic hash. You need one that is fast and spreads similar inputs (user-1, user-2, …) evenly across buckets. FNV-1a and MurmurHash3 are common choices. What matters most is that every SDK uses the same function the same way, so a user gets the same bucket in your Node backend, your Python worker and your mobile app.
Rollout checklist
- Start with internal users through a targeting rule, before any percentage.
- Go in steps (1%, 5%, 25%, 50%, 100%) and watch errors, latency and business metrics between steps.
- Keep the kill switch at hand: setting the flag to 0% or off must take effect within seconds.
- When you reach 100% and it is stable, schedule removing the flag from the code. See stale feature flags.