Feature Flag Chaos — When Your Configuration Becomes Unmanageable
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 entirelyThe 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 deleteThis 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