Back to Blog
DevelopmentMay 19, 202615 min read

Feature Flags Without Going Crazy

Feature flags are a simple idea that turns into silent debt. This article covers the full cycle: adding a flag with context, gradual rollout, and the part nobody documents: cleanup.

IM
Ignacio MelendezFull-Stack & Game Developer
Feature Flags Without Going Crazy

Feature flags start as a good idea. You need to ship a half-finished feature without breaking production, or test a new pricing flow with 5% of users before committing. You add a boolean, wrap the code, deploy. Done in ten minutes.

Then six months later you're looking at a codebase with forty flags, twelve of which nobody knows if they're still active, and three that have been "enabled permanently" for so long that removing them feels like defusing a bomb. That's not a tools problem. It's a discipline problem. And it starts at the moment you add the flag.

1. The Simplest Implementation That Doesn't Embarrass You

Before reaching for LaunchDarkly or any other external service, understand what a feature flag actually is: a named condition evaluated at runtime. The simplest possible version is a static object you import.

1// flags.ts
2export const flags = {
3  NEW_CHECKOUT: true,
4  DARK_MODE: false,
5  USAGE_BASED_BILLING: false,
6} as const
7
8// usage
9import { flags } from './flags'
10
11if (flags.NEW_CHECKOUT) {
12  return renderNewCheckout(cart)
13}
14return renderLegacyCheckout(cart)

This is fine for a single developer on a small project (being aware that it's not scalable over time). The problem is naming. A flag called NEW_CHECKOUT tells you nothing: new compared to what? When was it added? Is "new" still new? Good flag names carry a hint of their own lifecycle: CHECKOUT_V2_ROLLOUT, BILLING_MIGRATION_GATE, DARK_MODE_EXPERIMENT. The name should tell a future reader why the flag exists and roughly when it stops existing.

Name flags after the change they gate, not the feature they enable. ENABLE_NEW_CHECKOUT is worse than CHECKOUT_V2_ROLLOUT (the first could be permanent, the second implies a temporary transition).

2. Defining Flags With Enough Context

The single biggest cause of flag debt is adding a flag with no metadata. Six months later, nobody knows who owns it, what ticket it came from, or what condition would make it safe to remove. The fix is mechanical: make the flag carry that information from the start.

1type FeatureFlag = {
2  enabled: boolean
3  owner: string
4  ticket: string
5  removeAfter: string       // ISO date: when to revisit
6  removalCondition: string  // plain-English condition to remove
7}
8
9export const flags: Record<string, FeatureFlag> = {
10  CHECKOUT_V2_ROLLOUT: {
11    enabled: true,
12    owner: 'payments-team',
13    ticket: 'PAY-1234',
14    removeAfter: '2026-08-01',
15    removalCondition:
16      'Remove when V2 is at 100% rollout and no P1/P2 incidents for 2 weeks',
17  },
18  USAGE_BASED_BILLING: {
19    enabled: false,
20    owner: 'billing-team',
21    ticket: 'BILL-789',
22    removeAfter: '2026-07-15',
23    removalCondition:
24      'Remove when billing migration is complete for all enterprise accounts',
25  },
26}
27
28export function isEnabled(flagName: keyof typeof flags): boolean {
29  return flags[flagName]?.enabled ?? false
30}

This structure gives you two things immediately: a self-documenting flag that another developer can understand without reading the ticket, and a machine-readable expiry date you can use to build a stale-flag warning in your CI pipeline.

A good idea might be to add a CI check that fails if any flag has a removeAfter date older than 30 days and is still in the codebase. This converts cleanup from "someone should do this eventually" to "the build is broken until someone does this."

3. The Evaluator: Beyond Booleans

Static flags work until you need environment-specific overrides, user targeting, or percentage rollouts. At that point you need an evaluator, a function that takes a flag name and a context and returns a decision.

The context is the key design decision. It should contain everything the evaluator needs: the user ID, the environment, any attributes used for targeting (plan, region, account age). This keeps the evaluation logic centralized and the callsites clean.

1type EvaluationContext = {
2  userId: string
3  environment: 'production' | 'staging' | 'development'
4  attributes?: Record<string, string | number | boolean>
5}
6
7type FlagConfig = {
8  enabled: boolean
9  environments?: Array<'production' | 'staging' | 'development'>
10  rolloutPercentage?: number   // 0-100
11  allowlist?: string[]         // specific user IDs always enabled
12  blocklist?: string[]         // specific user IDs always disabled
13}
14
15function evaluate(flag: FlagConfig, ctx: EvaluationContext): boolean {
16  if (!flag.enabled) return false
17
18  if (flag.blocklist?.includes(ctx.userId)) return false
19  if (flag.allowlist?.includes(ctx.userId)) return true
20
21  if (flag.environments && !flag.environments.includes(ctx.environment)) {
22    return false
23  }
24
25  if (flag.rolloutPercentage !== undefined) {
26    return isInRollout(ctx.userId, flag.rolloutPercentage)
27  }
28
29  return true
30}

The evaluation order matters: blocklist before allowlist (safety first), environment filter before rollout (no point hashing users who wouldn't be eligible anyway), rollout last as the probabilistic gate.

4. Percentage-Based Rollout From Scratch

A percentage rollout has one key requirement: it must be consistent. If a user sees the feature on their first visit, they must see it on the hundredth. If a random number were used on each request, the feature would appear and disappear every time the user reloaded the page.

The solution is to convert the user ID into a fixed number between 0 and 99 via a hash. Each user is assigned a permanent locker number. If the rollout is 10%, users with lockers 0 through 9 see the feature; the rest don't. Since the hash always produces the same number for the same user, the assignment never changes between requests.

1import { createHash } from 'crypto'
2
3function stableBucket(userId: string, flagName: string): number {
4  // Including the flag name means different flags assign different buckets
5  // to the same user — prevents all flags from enabling simultaneously
6  const hash = createHash('sha256')
7    .update(`${flagName}:${userId}`)
8    .digest('hex')
9
10  // Take the first 8 hex chars → 32-bit integer → 0-99 bucket
11  const num = parseInt(hash.substring(0, 8), 16)
12  return num % 100
13}
14
15function isInRollout(userId: string, flagName: string, percent: number): boolean {
16  return stableBucket(userId, flagName) < percent
17}
18
19// Examples:
20// isInRollout('user_abc', 'CHECKOUT_V2_ROLLOUT', 10) → stable true/false
21// isInRollout('user_abc', 'DARK_MODE_EXPERIMENT', 10) → different result
22//   for the same user because the flag name changes the hash input

Do not use userId.hashCode() or any language-native hash function. Most hashCode implementations are not stable across process restarts or language versions. Use a cryptographic hash (SHA-256) with a fixed input format.

Including the flag name in the hash is a deliberate decision. Without it, all flags would use the same locker number for each user: the same 5% of people would see all experimental features at once. This concentrates bad experiences on the same group and makes experiment results unreliable. By including the flag name, each flag assigns users independently.

5. Flags Nobody Dares Touch

There's a feature flag in the payments service that's been enabled for two years. Nobody knows what happens if you disable it. Nobody has touched it.

This is the real problem with feature flags (not the implementation, but the accumulation). Flags accrete because adding them is cheap and removing them feels risky. Over time, the codebase fills with branches that nobody exercises, that complicate every refactor, and that represent invisible coupling between parts of the system.

The pattern is consistent: a flag is added during a rollout, the rollout completes, someone marks it enabled: true globally, and then it just... stays. The ticket to remove it is created but never prioritized. The developer who knew why it existed has moved on. Eighteen months later, the flag evaluates to true on every call, the false branch is dead code, and nobody knows if the dead branch has dependencies.

  • Dead branches accumulate: code that can never be reached but still has to be maintained, read, and understood.
  • Type safety degrades: conditional logic that could be a direct call becomes an if/else with two versions of the same types.
  • Tests lie: tests written when the flag was being rolled out may test both branches, but the enabled branch has drifted.
  • Refactoring is harder: you can't cleanly extract a function when it has two behaviors gated behind a flag.

6. The Cleanup Cycle

Cleanup needs to be automated enough that it doesn't rely on anyone remembering to do it. The removeAfter date from the flag definition is your trigger. The simplest version is a script that runs in CI and lists every expired flag:

1// scripts/check-stale-flags.ts
2import { flags } from '../src/flags'
3
4const today = new Date()
5const stale: string[] = []
6
7for (const [name, flag] of Object.entries(flags)) {
8  const removeAfter = new Date(flag.removeAfter)
9  if (removeAfter < today) {
10    stale.push(
11      `[STALE] ${name} — expired ${flag.removeAfter}, owner: ${flag.owner}, ticket: ${flag.ticket}`
12    )
13  }
14}
15
16if (stale.length > 0) {
17  console.error('Stale feature flags detected:')
18  stale.forEach(msg => console.error(msg))
19  process.exit(1)  // fail CI
20}
21
22console.log('All flags are within their removal window.')

When you do remove a flag, the process is straightforward: pick the branch that's now the permanent behavior, inline it, delete the other branch, delete the flag definition, and delete the flag evaluation from every callsite. The only thing that trips people up is fear: the feeling that "enabled permanently" might somehow differ from "the code it enables running unconditionally."

It doesn't. If a flag has been enabled for everyone for months with no incidents, its code is correct. Removing the conditional doesn't change any behavior. It just removes the complexity of a branch that can never be taken.

When removing a flag, it's a good idea to do it in two commits: first mark it as permanently enabled (removing the false branch), deploy and verify. Then delete the flag and the evaluation in a second commit. This will provide a safe rollback point in case removing the dead branch causes any unexpected issues.

7. Testing With Flags: The Case Everyone Ignores

Feature-flagged code has a testing problem that nobody talks about: most test suites only test one state of the flag. Usually the enabled state, because that's what the developer was working on when they wrote the tests. The disabled branch (the legacy path) goes untested and rots.

The correct approach is to test both branches explicitly, using a flag override in the test context. This also protects you during cleanup: if you have explicit tests for both branches, removing the disabled branch will cause those tests to fail until you also delete them, which forces the cleanup to be complete rather than partial.

1// checkout.test.ts
2import { evaluate } from '../src/evaluator'
3import { getCheckoutFlow } from '../src/checkout'
4
5const baseCtx = { userId: 'user_test', environment: 'production' as const }
6
7const flagEnabled = { enabled: true } as FlagConfig
8const flagDisabled = { enabled: false } as FlagConfig
9
10describe('checkout flow', () => {
11  it('renders V2 checkout when flag is enabled', () => {
12    const result = getCheckoutFlow(cart, evaluate(flagEnabled, baseCtx))
13    expect(result.version).toBe('v2')
14  })
15
16  it('renders legacy checkout when flag is disabled', () => {
17    const result = getCheckoutFlow(cart, evaluate(flagDisabled, baseCtx))
18    expect(result.version).toBe('legacy')
19  })
20})
21
22// When you remove the flag and inline the enabled branch:
23// - The flagDisabled test will fail immediately
24// - This forces you to delete that test — proof the cleanup is complete

There's a second failure mode that's harder to catch: tests written against a function that reads the flag internally, where the flag state is set in some test setup that nobody notices is there. When you delete the flag from the production code, the tests pass because the setup code still sets a value, it just doesn't do anything anymore. The test gives you false confidence that you actually verified both paths.

Avoid reading flags inside functions under test. Pass the evaluated flag result as a parameter instead. This makes the flag dependency explicit, removes the need for test setup that mutates global flag state, and makes both code paths testable without any mocking infrastructure.

The principle is simple: evaluate flags at the boundary (the controller, the request handler, the queue consumer), then pass the boolean result down to the functions that use it. Functions that receive a boolean don't know or care that it came from a flag, which means you can test them with a literal true or false and the test is honest.

Conclusions

Feature flags aren't a complex tool. The implementation is a hash function and a data structure. The difficulty is the discipline of treating every flag as temporary from the moment it's created, giving it an owner, a deadline, and a plain-English removal condition, then actually enforcing those through CI.

  • Name flags after the transition they gate, not the feature they enable.
  • Every flag definition must carry: owner, ticket, removeAfter date, plain-English removal condition.
  • Include the flag name in the rollout hash to prevent correlated exposure across flags.
  • Automate stale-flag detection in CI. Move cleanup from intention to obligation.
  • Test both flag states explicitly, not through global setup that can silently become a no-op.
  • Evaluate flags at the boundary; pass the result as a parameter to the functions that use it.

The flags that have been "enabled permanently for two years" don't exist because developers are careless. They exist because the system didn't make removing them the natural thing to do. Using required metadata from the start and the automated stale-flag check, most of those conversations will never happen.