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

  1. Flags are temporary. Every flag has a removal date. Flags that outlive their purpose are tech debt.
  2. Default to off. A new flag defaults to disabled. If you default to enabled, it's not a flag — it's live code.
  3. Flags are not secrets. Do not use flags to hide sensitive business logic — use authorisation for that.
  4. Test both code paths. A flag that only has tests for the "on" state will break in production when turned off.
  5. 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 FLAGS enum
  • [ ] Delete the flag from the flag management system
  • [ ] Remove all tests for the false code 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