Feature Flags: A Practical Guide
A feature flag is a conditional wrapped around a piece of code that lets you turn that code on or off — for everyone, for a subset of users, or gradually — without shipping a new deploy. The idea is simple enough that it's tempting to just sprinkle if statements through the codebase, and that's exactly how feature flags go wrong at scale. Done well, they decouple deploying code from releasing it; done carelessly, they turn into a permanent maze of dead branches nobody dares delete.
The simplest possible version
At its core, a feature flag is just a boolean check:
if (flags.isEnabled('new-checkout-flow', { userId: user.id })) {
return renderNewCheckout();
}
return renderLegacyCheckout();
The interesting part isn't the if — it's flags.isEnabled. Where does that answer come from, and how fast can it change?
Three ways to evaluate a flag
Static config. A JSON or YAML file, or environment variables, checked at deploy time. Simplest to implement, but changing a flag means a redeploy — which defeats a large part of the point (fast, low-risk toggling).
const flags = { 'new-checkout-flow': process.env.FF_NEW_CHECKOUT === 'true' };
A remote flag service, queried (and cached) at runtime, so a flag can be flipped from a dashboard without touching the deploy pipeline at all:
async function isEnabled(flagKey, context) {
const cached = cache.get(flagKey);
if (cached) return evaluate(cached, context);
const flag = await flagService.fetch(flagKey);
cache.set(flagKey, flag, { ttl: 30_000 });
return evaluate(flag, context);
}
Percentage or attribute-based rollout, where the flag isn't just on/off but targets a slice of traffic — 5% of users, or everyone whose plan === 'enterprise':
function evaluate(flag, context) {
if (!flag.enabled) return false;
if (flag.rolloutPercent != null) {
const bucket = hashToPercent(context.userId, flag.key);
return bucket < flag.rolloutPercent;
}
if (flag.rules) {
return flag.rules.every((rule) => matchesRule(rule, context));
}
return true;
}
function hashToPercent(userId, flagKey) {
const hash = simpleHash(`${userId}:${flagKey}`);
return hash % 100;
}
The hashToPercent piece matters more than it looks: hashing on userId (rather than randomizing per request) means the same user consistently lands on the same side of the flag across page loads — otherwise a user could see the new checkout flow on one request and the old one on the next, which is confusing and makes bugs nearly impossible to reproduce.
What flags are actually for
Release flags decouple deploy from release: merge and ship code dark, then flip it on when you're ready, independent of the next deploy window. Experiment flags run an A/B test — a percentage of users get variant B, and you measure the difference. Ops flags are kill switches for degrading gracefully — turn off a slow, non-critical feature (recommendations, a third-party widget) under load without a deploy. Permission flags gate a feature to specific users or plans, like an enterprise-only feature — though for long-lived access control, a proper entitlements system usually serves you better than a flag that was meant to be temporary.
That distinction matters because each type has a different lifecycle. Ops flags might live forever. Release and experiment flags should not.
The part everyone skips: deleting them
A flag that's been at 100% for six months and has no code path left that checks the old behavior isn't providing safety anymore — it's dead weight that makes the codebase harder to read and every future change riskier, because now there are two paths to reason about instead of one.
// Six months later, this still exists:
if (flags.isEnabled('new-checkout-flow')) {
return renderNewCheckout();
}
return renderLegacyCheckout(); // dead code nobody has run in months, still shipped, still maintained
Treat flag cleanup as part of the feature's definition of done, not an optional follow-up: once a flag has been fully rolled out and stable for a reasonable period, remove the flag check and the dead branch in the same pass. Some teams enforce this with a linter that flags (no pun intended) any flag check older than N days, or a dashboard that surfaces flags nobody has toggled recently.
A pitfall: flags that interact
Two independent-looking flags can combine into a state nobody tested:
if (flags.isEnabled('new-checkout-flow')) {
if (flags.isEnabled('new-payment-provider')) {
return renderNewCheckoutWithNewPayments(); // did anyone actually test this combination?
}
return renderNewCheckoutWithOldPayments();
}
Every additional flag doubles the number of code paths in the parts of the app it touches. Two flags is four paths; three is eight. Keep flags scoped narrowly and short-lived, and be deliberate about which combinations you actually need to support — testing every combination isn't realistic once you have more than a couple of flags active in the same area of code.
Takeaway
Feature flags buy you the ability to ship code without releasing it, roll out gradually, and kill a misbehaving feature without a deploy — genuinely valuable properties. The failure mode isn't the flag itself, it's treating flags as permanent instead of temporary: every flag you add is a debt that needs to be paid off by eventually removing it, and a codebase that never pays that debt back ends up harder to reason about than one with no flags at all.