Feature Flags
You are implementing or reviewing feature flags for: $ARGUMENTS
Feature flags are a deployment and risk management tool. They decouple code deployment from feature activation, enabling gradual rollouts, instant rollback, and A/B testing. Used poorly, they become permanent dead code that confuses everyone.
Principles
- Flags are temporary. Every flag has a removal date. Flags that outlive their purpose are tech debt.
- Default to off. A new flag defaults to disabled. If you default to enabled, it's not a flag — it's live code.
- Flags are not secrets. Do not use flags to hide sensitive business logic — use authorisation for that.
- Test both code paths. A flag that only has tests for the "on" state will break in production when turned off.
- Flags degrade gracefully. If the flag service is unreachable, fall back to the default (off).
1. Flag Types
| Type | Purpose | Lifetime |
|---|---|---|
| Release flag | Hide incomplete feature in production | Days to weeks |
| Experiment flag | A/B test — measure behaviour difference | 1–4 weeks |
| Ops flag | Kill switch for non-critical features under load | Permanent (operational) |
| Permission flag | Enable feature for specific users/accounts | Permanent (business rule) |
Use the right type. Treating a permission flag as a release flag leads to never removing it.
2. Implementation Patterns
Simple Key/Value Flag
// flags.ts — single source of truth for flag names
export const FLAGS = {
NEW_CHECKOUT_FLOW: 'new-checkout-flow',
ENHANCED_SEARCH: 'enhanced-search',
ML_RECOMMENDATIONS: 'ml-recommendations',
} as const;
// Usage
if (featureFlags.isEnabled(FLAGS.NEW_CHECKOUT_FLOW, { userId })) {
return renderNewCheckout();
}
return renderLegacyCheckout();
Server-Side Evaluation (Recommended)
Evaluate flags on the server — never trust the client to hide features:
// Never do this:
// <button v-if="flags.newFeature">Click me</button>
// A user can inspect the DOM and enable the flag client-side.
// Do this instead: guard at the API/controller level
router.get('/api/checkout', async (req, res) => {
const enabled = await flags.isEnabled('new-checkout-flow', { userId: req.user.id });
if (enabled) {
return newCheckoutController(req, res);
}
return legacyCheckoutController(req, res);
});
Percentage Rollout
Consistent assignment: the same user always gets the same variant.
function isInRolloutPercentage(userId: string, flagKey: string, percentage: number): boolean {
// Hash (userId + flagKey) to get a stable value 0–99
const hash = murmurhash3(userId + flagKey) % 100;
return hash < percentage;
}
// 10% rollout → only 10% of users see the feature
// 50% rollout → half of users
// Always the same users for the same flagKey (consistent experience)
3. Rollout Strategy
Week 1: 0% → Internal users only (flag for employee accounts)
Week 2: 5% → Canary — watch error rates and performance metrics
Week 3: 20% → If metrics stable, expand
Week 4: 50% → Majority; review support ticket volume
Week 5: 100% → Full rollout; schedule flag removal
Week 6: — → Remove flag and dead code path
Rollback: Set flag to 0% or OFF in the flag management UI. No deployment needed.
4. Using a Flag Service
LaunchDarkly (cloud)
import { init } from '@launchdarkly/node-server-sdk';
const client = init(process.env.LAUNCHDARKLY_SDK_KEY);
await client.waitForInitialization();
const isEnabled = await client.variation(
'new-checkout-flow',
{ key: userId, email: userEmail },
false // default value if SDK fails
);
Unleash (self-hosted)
import { initialize } from 'unleash-client';
const unleash = initialize({
url: 'http://unleash.internal/api',
appName: 'myapp',
customHeaders: { Authorization: process.env.UNLEASH_TOKEN }
});
const isEnabled = unleash.isEnabled('new-checkout-flow', { userId });
Minimal In-House Flag (env-based, no external service)
Suitable for simple cases:
// flags.config.json (injected per environment via CI/CD or mounted secret)
{
"new-checkout-flow": { "enabled": true, "rollout": 25 },
"ml-recommendations": { "enabled": false, "rollout": 0 }
}
// flags.ts
import config from './flags.config.json';
export function isEnabled(key: string, context?: { userId?: string }): boolean {
const flag = config[key];
if (!flag?.enabled) return false;
if (flag.rollout >= 100) return true;
if (!context?.userId) return false;
return isInRolloutPercentage(context.userId, key, flag.rollout);
}
5. Testing Feature Flags
Always test both the enabled and disabled code paths:
// Jest
describe('checkout', () => {
it('renders new checkout when flag is on', async () => {
jest.spyOn(flags, 'isEnabled').mockResolvedValue(true);
const res = await request(app).get('/checkout');
expect(res.text).toContain('new-checkout');
});
it('renders legacy checkout when flag is off', async () => {
jest.spyOn(flags, 'isEnabled').mockResolvedValue(false);
const res = await request(app).get('/checkout');
expect(res.text).toContain('legacy-checkout');
});
});
6. Flag Lifecycle and Removal
Removal Checklist
When a flag reaches 100% rollout and the rollout is stable:
- [ ] Confirm 100% rollout has been stable for ≥ 1 week
- [ ] Delete the
false(off) code path - [ ] Remove the flag check
- [ ] Remove the flag constant from
FLAGSenum - [ ] Delete the flag from the flag management system
- [ ] Remove all tests for the
falsecode path - [ ] Update any documentation that references the flag
// Before removal: flag check in place
if (flags.isEnabled(FLAGS.NEW_CHECKOUT_FLOW, { userId })) {
return renderNewCheckout();
}
return renderLegacyCheckout();
// After removal: just the new path, no flag
return renderNewCheckout();
Flag Inventory
Maintain a docs/feature-flags.md or equivalent:
| Flag | Type | Default | Rollout | Owner | Remove by |
|---|---|---|---|---|---|
| new-checkout-flow | release | off | 100% | @alice | 2026-04-15 |
| ml-recommendations | experiment | off | 25% | @bob | 2026-05-01 |
| maintenance-mode | ops | off | — | @devops | permanent |
7. Common Mistakes
| Mistake | Why it's a problem | Fix |
|---|---|---|
Flag defaults to true |
New environments start broken | Default to false always |
| No removal date | Flag becomes permanent dead code | Set a calendar reminder on creation |
| Flag checking in data layer | Hard to test; mixes concerns | Only check flags at the request/controller layer |
| Same flag for A/B test and kill switch | Conflated lifecycle | Use separate flags for separate purposes |
| Flag name is an internal code name | Confusing in flag management UI | Use a human-readable, intent-clear name |