Feature Flag Chaos — When Your Configuration Becomes Unmanageable

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Feature flags are one of the most powerful tools in modern software delivery — they decouple deployment from release, enable gradual rollouts, and allow instant rollbacks without a deploy. They also accumulate like technical debt when not actively managed. A codebase with 200 unmanaged flags is harder to reason about than one with none at all. Every conditional branch is a question the next engineer has to answer: is this flag still relevant? What does it default to? Who owns it?

The Feature Flag Lifecycle

Every flag should move through a defined lifecycle, or it accumulates permanently:

1. CREATED    — Flag added for gradual rollout, A/B test, or ops control
2. RAMPING    — Gradually rolled out (10% → 50% → 100%)
3. PERMANENT  — Rolled out to 100% of users, both branches active in code
4. CLEANUP    — Old code path removed, only new behavior remains
5. ARCHIVED   — Flag deleted from the flag system entirely
 
Most teams do steps 1-3 and never complete 4 and 5.
The flag stays in the code as dead branches forever.
After 18 months there are 200 flags and nobody knows which matter.

The fix is to treat cleanup as a required part of the feature lifecycle, not an optional follow-up.

Ownership and Expiry: The Core Fix

Every flag must have a declared owner and a cleanup deadline. Without these, flags are orphaned by default.

// flags-registry.js — checked into version control alongside code
const flags = [
  {
    key: 'new-checkout-flow',
    description: 'New checkout flow with guest support',
    owner: 'checkout-team',
    type: 'release',        // release | experiment | ops | permission
    cleanupBy: '2026-04-15', // 4 weeks from creation is typical
    defaultValue: false,
  },
  {
    key: 'new-pricing-algorithm',
    description: 'ML-based dynamic pricing (A/B test)',
    owner: 'pricing-team',
    type: 'experiment',
    cleanupBy: '2026-05-01',
    defaultValue: false,
  },
]
 
// CI check: fail the build if any flag is past its cleanup date
function checkFlagExpiry(flags) {
  const today = new Date().toISOString().split('T')[0]
  const expired = flags.filter(f => f.cleanupBy < today)
  if (expired.length > 0) {
    console.error('EXPIRED FLAGS — clean these up before merging:')
    expired.forEach(f =>
      console.error(`  ${f.key} (owner: ${f.owner}, expired: ${f.cleanupBy})`)
    )
    process.exit(1)
  }
}
 
checkFlagExpiry(flags)

When the CI pipeline blocks merges for expired flags, cleanup becomes urgent rather than deferred.

Clean Up After Rollout: Remove Dead Branches

The most common flag mistake is leaving the flag in code after the rollout is complete:

// Bad: flag left in code after full rollout
async function processCheckout(cart, userId) {
  if (await flags.isEnabled('new-checkout-flow', userId)) {
    return newCheckoutService.process(cart)
  } else {
    return oldCheckoutService.process(cart)  // dead code — flag is 100% enabled
  }
}
 
// Good: after cleanupBy date, the engineer removes the flag and old code path
async function processCheckout(cart) {
  return newCheckoutService.process(cart)
  // Old code path deleted — no dead branches, no flag evaluation overhead
}
// AND removes 'new-checkout-flow' from the flags service entirely

The cleanup task is two steps: remove the conditional from the code, and delete the flag from the flag management system. Both are required.

Typed Flag Keys to Prevent Silent Failures

String-based flag lookups fail silently on typos — the flag just returns its default value:

// Bad: string-based flag — typo silently returns false
if (await flags.isEnabled('new-checout-flow', userId)) {
  // never runs because 'checout' != 'checkout' — no error thrown
}
 
// Good: typed flag registry catches typos at review time or with linting
const FLAG_KEYS = {
  NEW_CHECKOUT_FLOW: 'new-checkout-flow',
  NEW_PRICING_ALGORITHM: 'new-pricing-algorithm',
  BETA_SEARCH: 'beta-search',
}
 
// Usage
if (await flags.isEnabled(FLAG_KEYS.NEW_CHECKOUT_FLOW, userId)) {
  // If you mistype FLAG_KEYS.NEW_CHEKOUT_FLOW, it's undefined — visible error
}

In TypeScript projects, you can make this a compile-time check by typing the flag client with the allowed key union.

Flag Evaluation Observability

Track which flags are actually evaluating to non-default values. Flags with zero non-default evaluations for 30 days are safe to delete:

class FlagClient {
  async isEnabled(flagKey, userId) {
    const result = await this.evaluate(flagKey, userId)
 
    // Track every evaluation — surfaces unused flags
    metrics.increment('feature_flag.evaluated', {
      flag: flagKey,
      result: String(result),
      environment: process.env.NODE_ENV,
    })
 
    return result
  }
}
 
// Weekly query: which flags have had 0 non-default evaluations in 30 days?
// SELECT flag_key, count(*) as evals
// FROM flag_evaluations
// WHERE result != default_value
// AND evaluated_at > NOW() - INTERVAL '30 days'
// GROUP BY flag_key
// HAVING count(*) = 0
// → These are safe to delete

This also catches typo flags — if a flag key has zero evaluations total, it was likely never called correctly.

Flag Count Monitoring

Alert when the total active flag count crosses a threshold. High flag counts indicate accumulation:

async function auditFlags() {
  const activeFlags = flags.filter(f => f.status === 'active')
  const expiredFlags = flags.filter(f => f.cleanupBy < new Date().toISOString().split('T')[0])
 
  metrics.gauge('feature_flags.active_count', activeFlags.length)
  metrics.gauge('feature_flags.expired_count', expiredFlags.length)
 
  if (activeFlags.length > 50) {
    await alerting.send({
      severity: 'warning',
      message: `Feature flag count is ${activeFlags.length} — review and clean up expired flags`,
      runbook: 'https://wiki.internal/flag-cleanup',
    })
  }
 
  if (expiredFlags.length > 0) {
    await alerting.send({
      severity: 'critical',
      message: `${expiredFlags.length} expired feature flags need cleanup`,
      flags: expiredFlags.map(f => ({ key: f.key, owner: f.owner })),
    })
  }
}

A healthy codebase keeps active flag count below 50. Teams that keep it below 20 have essentially no flag management overhead.

Key Takeaways

  • Every feature flag must have a declared owner and a cleanup deadline — flags without both are orphaned by default
  • CI should fail builds when any flag is past its cleanup date, making cleanup urgent rather than deferred
  • Cleanup is two steps: remove the conditional from the code AND delete the flag from the management system
  • Typed flag key constants prevent silent typo failures where the wrong default is returned
  • Track flag evaluations by flag key — flags with zero non-default evaluations for 30 days are safe to delete
  • Alert when active flag count exceeds 50 — high counts indicate accumulation, not healthy usage
  • Four flag types cover all use cases: release (gradual rollout), experiment (A/B test), ops (infrastructure control), permission (entitlement)

Conclusion

Feature flags rot like any other unmanaged code. The cure is to treat every flag like a ticket with a due date: create it with an owner and a cleanup deadline, block merges when the deadline passes, and delete both the flag and the dead code path after rollout. A codebase with 20 well-managed flags is more maintainable than one with 200 flags nobody owns. Keep the count low, the owners clear, and the cleanup dates enforced by automation rather than process.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading