How to Do a Percentage Rollout with Feature Flags
A practical guide to percentage rollouts: sticky assignment, 10% then 50% then 100%, kill criteria, and how gradual rollouts differ from canary deploys.
A percentage rollout is how you stop betting the company on a Friday deploy. You ship the code to everyone, behind a feature flag, and you turn the feature on for 10% of users. If checkout holds, you go to 50%, then 100%. If it does not, you go to 0 without a revert.
That is progressive delivery in the small. No new build between steps. The flag percentage is the release.
Sticky assignment, or your metrics are noise
The naive implementation is Math.random() < 0.1. That gives you 10% of requests, not 10% of users. A user will flip between old and new checkout as they navigate. Your funnel becomes unreadable. Support gets screenshots of both UIs from the same person.
Hash a stable id:
import { createHash } from "node:crypto";
function inRollout(userId: string, percent: number, flagKey: string): boolean { const digest = createHash("sha256") .update(`${flagKey}:${userId}`) .digest("hex"); const bucket = parseInt(digest.slice(0, 8), 16) % 100; return bucket < percent;}
Including the flag key in the hash means a user who was in the 10% for checkout-v2 is not automatically in the 10% for editor-v3. Independent experiments stay independent.
You should not write this yourself in production. Your SDK already does it. The point is to know what "10%" means: the same 10% of user ids, every request, until you change the percentage.
Anonymous users need a cookie id set on first visit. Logins should alias that cookie to the account id so a user does not jump buckets when they sign in. IPs are a bad key.
A ramp you can defend
There is no universal schedule. There is a schedule that matches your blast radius.
A default I like for user-facing product changes:
| Step | Share | How long | What you watch | |---|---|---|---| | Internal | staff / your user id | hours | Does it even load? | | Beta | named cohort | a day | Support, obvious bugs | | 10% | sticky percentage | hours to a day | Errors, checkout, latency | | 50% | sticky percentage | hours to a day | Same, plus "did the 10% hold" | | 100% | everyone | stay here, then delete the flag | Same |
Skip steps on a copy change. Do not skip steps on payments, auth, or a new query plan.
Ramp in the dashboard, the API, or from Claude Code over MCP. The code does not change:
const showV2 = await flags.isEnabled("checkout-v2", { userId: user.id });return showV2 ? <CheckoutV2 /> : <CheckoutV1 />;
isEnabled already knows the percentage. You are not passing 0.1 from the client. If you are, users can tamper with it.
Kill criteria before you need them
A rollout without kill criteria is a delayed 100% ship. Write the line in the PR:
- Checkout conversion drops more than X% relative to the control window.
- 5xx on the new path exceeds Y.
- p95 latency on
/api/checkoutexceeds Z. - Payment processor error rate doubles.
If a line is crossed, set the flag to off. That is a kill switch. Debate the threshold on Monday, not during the incident.
The off path has to exist. If you deleted <CheckoutV1 /> because "we will only ever go forward," you cannot roll back with a flag. You are waiting on a deploy again.
Percentage rollouts vs canary deploys vs A/B tests
Canary deploy: a new build on a subset of servers. Catches "this binary segfaults" and "this migration locks the table." Does not catch "the new checkout confuses 8% of users." You still want canaries for infrastructure.
Percentage rollout: one build, subset of users. Catches product and application bugs. This is the feature flag job.
A/B test: a percentage split plus a stats engine. Use it when you need to know whether B is better, not just whether B is safe. If you do not have the traffic or the patience for significance, you wanted a rollout, not an experiment. Serious experimentation belongs in Statsig or GrowthBook, not a flags-only product.
Betterflag does rollouts and targeting. It does not pretend to be a stats engine.
Targeting plus percentage
A useful composition: "beta cohort OR 10% of everyone else." Staff always on, the world ramps. Keep the rule readable. Nested AND/OR novels are how you ship to the wrong 10%. Best practices is the hygiene list.
After 100%
Leave the flag at 100% for a few days in case you need to kill it. Then delete the flag and the old branch. A flag that is 100% on forever is an if with extra latency.
Calendar the removal when you create the flag. Owners, not "eng."
A rollout checklist
- Sticky hash on user id, namespaced by flag key.
- Anonymous cookie, aliased on login.
- Internal → beta → 10% → 50% → 100%, skipped only when the blast radius is tiny.
- Kill criteria written in the PR.
- Off path still in the bundle.
- Delete the flag after 100% holds.
That is percentage rollouts. The React and Next.js wiring is in feature flags in React and Next.js. The product that does the percentage, the targeting, and the kill switch on one meter is Betterflag. Pricing starts at $9.99/mo; alpha waitlist is 50% off for life.
FAQ
- What is a percentage rollout?
- A percentage rollout (also called a gradual rollout or progressive delivery) sends a feature to a fraction of users, then ramps that fraction up if it holds. Feature flags do this at request time, without a new deploy between 10%, 50%, and 100%.
- How is a percentage rollout different from a canary deploy?
- A canary deploy ships a new build to a subset of servers. A percentage rollout ships one build to all servers and shows the new feature to a subset of users. Canaries catch infrastructure bugs. Rollouts catch product bugs. You often want both.
- How do you keep percentage rollouts sticky?
- Hash a stable user id and compare it to the percentage. The same user stays in the same bucket as you ramp from 10% to 50%, as long as the hash is consistent. Do not pick at random per request, or users will flicker between variants.
- When should you kill a percentage rollout?
- Before you start, write the kill criteria: error rate, checkout conversion, latency, a support spike. If a number crosses the line, set the flag to 0% (or off). Do not debate it during the incident. The off path has to still exist in the code.
