devShakib

Shipping to Everyone at Once Is a Choice, and Usually the Wrong One

Feature flags separate deploy from release: ramp progressive rollouts by percentage, gate on metrics, and roll back in seconds with a kill switch, no redeploy.

Early in my career I shipped a "small" change to every user of a payments flow at 11pm on a Thursday, because that was when traffic was lowest and I was feeling brave. The change was correct in staging, correct in code review, and quietly wrong for a subset of users on an older app version. By the time the first support ticket landed, roughly forty thousand people had already hit the broken path. The fix was easy. The redeploy took eighteen minutes. Those eighteen minutes felt like a year.

I don't do that anymore, and neither should you. Not because I got more careful — same person — but because I stopped treating "deploy" and "release" as the same word. Shipping new code to 100% of your users the instant it lands is a choice. It's rarely the right one, and the tooling to avoid it — feature flags, progressive rollouts, and kill switches — is cheaper and simpler than most teams think. This post is the playbook I wish I'd had that Thursday night.

Deploy vs release: why conflating them is the actual bug

One mental shift does most of the work here. Deploy and release are two different verbs that most teams spell the same way:

When these two are welded together, every deploy is a release, and every release is a bet on the entire user base at once. That's why deploys feel dangerous. You've turned a routine git push into a live grenade toss.

The fix is to make the code path present but dormant. The new behavior ships to production, sits behind a flag that's off, and does nothing until you decide otherwise. Now the deploy is genuinely boring — the code is just there. The interesting, risky part — turning it on — happens later, on your schedule, at whatever percentage you choose. This decoupling is the foundation of continuous delivery: you can merge to main and deploy ten times a day precisely because deploying no longer means releasing.

I'll say the quiet part out loud: the goal of good operations is boredom. Not heroics, not war rooms, not a Slack channel full of rocket emojis at 2am. A deploy nobody notices is a deploy that went well.

Anatomy of a progressive rollout that can't page you

Picture a change you're genuinely nervous about — say, a new pricing calculation. Here's the version that can't wake you up:

Nowhere in that sequence are you holding your breath. Each step is small enough that a mistake is recoverable and observable. The blast radius is a dial, not a switch. That word — blast radius — is the whole game: at 1%, a catastrophic bug affects one in a hundred users for a couple of minutes, not everyone forever.

Without flags, your rollback plan is "redeploy the previous version and pray the migration is backward-compatible." With flags, it's "type 0 and press enter." One of these is a plan. The other is a wish.

Feature flags as a control plane, not a pile of if-statements

Most teams start with feature flags and end up with a swamp of if checks scattered through the codebase, read from environment variables, hardcoded booleans, the occasional if (user.email.endsWith('@ourcompany.com')). That's not a flag system. That's technical debt with good intentions.

A flag is a control plane: a single place where the decision lives, separate from the code that acts on it. The code asks a question — "should this user see the new pricing?" — and something outside the code answers. That separation is the whole point. It's what lets a non-deploy change behavior. The evaluation is a pure function of context in, boolean out; the policy (what percentage, which users, which app versions) lives in config you can change without touching a deploy pipeline.

In a Flutter app talking to Firebase, my cheapest version of this is Remote Config. The client asks; the config answers; the answer can change without an app-store release — which matters enormously on mobile, where a "redeploy" can mean days of review latency.

class Flags {  final FirebaseRemoteConfig _rc;  Flags(this._rc);  /// Deterministic per-user bucketing so a user's experience  /// is stable across sessions, not random on every launch.  bool isOn(String key, {required String userId, int rollout = 0}) {    if (_rc.getBool('${key}_force_on')) return true;    final pct = _rc.getInt('${key}_rollout') != 0        ? _rc.getInt('${key}_rollout')        : rollout;    final bucket = (userId.hashCode & 0x7fffffff) % 100;    return bucket < pct;  }}

Two things matter here. First, bucketing is deterministic — the same user always lands in the same bucket, so features don't flicker on and off between screens. (In a real system I'd salt the hash with the flag key so a user isn't in the same bucket for every flag; otherwise the unlucky 1% catches every experiment. '$key:$userId'.hashCode is enough.) Second, the _force_on override lets me flip the flag true for internal accounts regardless of the percentage. Small details, but they're the difference between a flag you trust and one that surprises you.

On the backend, the same idea holds. The evaluation logic lives in one module, everything else calls into it:

type FlagContext = { userId: string; appVersion: string; country: string };function evaluate(flag: string, ctx: FlagContext): boolean {  const rule = rules[flag];  if (!rule || rule.enabled === false) return false;  if (rule.allowUsers?.includes(ctx.userId)) return true;  if (rule.minAppVersion && semverLt(ctx.appVersion, rule.minAppVersion)) {    return false; // don't ship new behavior to old clients  }  return bucket(ctx.userId, flag) < (rule.rolloutPct ?? 0);}

Notice the minAppVersion guard. That one line is exactly what would have saved me from my 11pm payments incident — new behavior simply doesn't turn on for clients too old to handle it. Client targeting like this (by app version, OS, country, or account tier) is the difference between a percentage rollout and a safe percentage rollout. The control plane is where you encode that kind of knowledge once, instead of remembering it under pressure.

Percentage rollouts, kill switches, and the metrics that gate them

There are really two families of flags, and mixing them up is a common mistake:

A kill switch is the flag you're most grateful for at exactly the moment you can least afford to write code. When a vendor's API starts timing out and dragging your whole app down with it, you don't want to be shipping a hotfix. You want to flip enrichment_enabled to false and degrade gracefully. The best-designed systems wrap every risky external dependency — payment providers, push services, ML inference, anything with a network hop you don't control — in one of these from day one.

The part teams skip: a rollout that isn't gated by a metric is just a slower way to break everything. Ramping to 100% while ignoring your dashboards means you'll reach full breakage in five careful steps instead of one. Before you touch the dial, decide:

On a recent rollout at Shpper we caught a regression at the 5% step: a new caching layer was returning stale data for one segment. Crash-free was fine, but the product metric — items added to cart — dropped for that cohort. We set the flag to 0, fixed the cache key, and re-ramped two days later. Total impact: a few hundred people, for under an hour. Without the metric gate, we'd have shipped it to everyone and found out from a revenue report a week later. That gap — between "an hour, a few hundred users" and "a week, everyone" — is the return on investment for this whole practice.

Tagging metrics with flag state

The trick that makes metric-gating actually work is emitting the flag's value alongside every metric and log line, so your dashboard can compare "flag on" against "flag off" in real time:

final onNewPricing = flags.isOn('rel_new_pricing', userId: user.id);analytics.logEvent('checkout_completed', {  'total': total,  'rel_new_pricing': onNewPricing, // slice every metric by flag state});

Now a regression shows up as a divergence between two cohorts on the same build, which rules out "it was already broken" and points straight at the flag. Without this dimension, a 5% rollout is a 5% dilution of your signal — the bad cohort is drowned out by the 95% who are fine.

Flag debt: the mess you're quietly creating

Now the honest part, because flags are not free. Every flag is a fork in your code, and forks multiply. Ten flags is 2^10 theoretical states. Most combinations are nonsense, but the cognitive load is real. I've inherited codebases where a flag sat at 100% for two years and nobody dared remove it because they weren't sure what the "off" branch did anymore.

Flag debt is the interest you pay for the safety flags give you. Manage it deliberately:

The flags that are supposed to be permanent (kill switches) stay. The flags that were supposed to be temporary (release flags) must actually leave. Confusing the two is how a control plane rots into that pile of if-statements I warned you about.

Testing a system where any flag can be on or off

"But now my code has infinite states" is the objection I hear most, and it's fair. The answer isn't to test every combination — that's the combinatorial explosion you can't win — it's to make combinations not matter.

test('checkout uses new pricing when rel_new_pricing is on', () {  final flags = FakeFlags({'rel_new_pricing': true});  final total = Checkout(flags).total(cart);  expect(total, equals(expectedNewPrice));});test('checkout uses legacy pricing when flag is off', () {  final flags = FakeFlags({}); // everything off  final total = Checkout(flags).total(cart);  expect(total, equals(expectedLegacyPrice));});

A FakeFlags that takes a map is maybe fifteen lines and it's the highest-leverage test helper I write on any flagged project. In CI I run the suite twice for the flags that gate risky paths — once with the flag forced on, once off — so a green build guarantees both branches work. That's far cheaper than exponential coverage and it catches the failure that matters: shipping a branch nobody exercised.

Rollbacks in seconds instead of a redeploy

Let me put a number on the thing that makes all of this worth it. A traditional rollback is: notice the problem, decide to revert, run the pipeline, wait for the build, wait for the deploy, wait for the caches and CDNs. That's ten to twenty minutes on a good day, and it assumes the previous version is still safe to run against your now-migrated database.

A flag rollback is: set the value to 0. Seconds. And because the "off" path is the code that was already running in production a moment ago, you know it's safe — you're not rolling back to an old artifact, you're just declining to run the new branch. This collapses your mean-time-to-recovery from minutes to seconds, and MTTR, not incident count, is what your users actually feel.

This is the reframing that sells flags to skeptical engineers: a flag isn't a feature toggle, it's an undo button for production. The percentage rollout is just undo with a granularity dial. Once you've flipped a bad feature off in three seconds during an incident, you never want to be without it again.

One caveat worth stating plainly: flags undo behavior, not data. If your new code path wrote bad rows to the database, turning the flag off stops the bleeding but doesn't clean the wound. That's why the riskiest flags to me are the ones that mutate persistent state — I ramp those slowest, and make sure the write is idempotent or reversible before I touch the dial. When a change involves a schema migration, decouple it further with the expand/contract pattern: deploy the additive schema change first, ramp the code that uses it behind a flag, and only remove the old columns once the flag is at 100% and cleaned up.

The lean feature-flag setup I use before reaching for a vendor

There are excellent flag vendors, and on a big team with a real budget I'd use one. But I ship lean, and for most of my projects the honest truth is you can start with almost nothing. Here's the progression I actually follow:

The mistake is jumping to Level 3 on day one, or never leaving Level 0 and wondering why rollouts feel scary. The middle two levels — deterministic evaluation and flag-tagged metrics — are where the boredom I keep promising actually comes from.

Key takeaways

The whole point is boredom. If your deploys still feel like an event, you're releasing to everyone at once — and that's a choice you can stop making this week.