Feature Flags: The Hidden Complexity Behind Simple Toggles


The feature flag was supposed to make it safe to deploy the new checkout flow. Six months later: the flag is still in the code, the “off” branch is untested and has drifted from the “on” branch, three engineers have added conditional logic that references it, and the product manager says “we’ll clean it up later.” Nobody cleans it up. Eventually someone deploys with the flag off by mistake. The checkout flow regresses to behavior the team doesn’t remember writing.

This is the most common way feature flags fail teams. Not the initial implementation - the lifecycle.

What Feature Flags Are Actually For

A feature flag is a conditional that controls which code path runs, controlled by configuration rather than code. The configuration can change without a deployment.

That last part is the value: you can change production behavior without deploying. This enables:

Decoupling deploy from release. Code goes to production but stays inactive. You ship the code when it’s ready, enable the feature when the business decides to launch it.

Gradual rollout. Enable for 1% of users, watch error rates and latency, increase to 10%, then 100%. If something breaks, you can disable without rolling back a deployment.

Kill switches. A feature that’s causing problems can be disabled instantly, faster than a rollback. For payment flows, third-party integrations, or high-risk features, a kill switch is worth the complexity.

A/B testing. Different behavior for different user segments, with metrics to compare outcomes.

These are distinct use cases with different requirements. Confusing them creates the problems that make feature flags painful.

The Implementation Spectrum

A simple env-var flag:

import os

def checkout(cart):
    if os.getenv('NEW_CHECKOUT_ENABLED') == 'true':
        return new_checkout_flow(cart)
    return old_checkout_flow(cart)

This works for kill switches and deploy/release decoupling. Requires a redeployment to change. No user targeting. Fine for many use cases.

A flag service (LaunchDarkly, Unleash, GrowthBook, Flagsmith):

def checkout(cart, user):
    if flag_service.is_enabled('new-checkout', user_id=user.id, context={
        'plan': user.plan,
        'country': user.country,
    }):
        return new_checkout_flow(cart)
    return old_checkout_flow(cart)

No redeployment needed to change. Supports percentage rollout, user targeting, environment-specific state. Adds a network call on every flag evaluation (or a cached local copy with TTL). Adds an external dependency.

The choice: if you need percentage rollout or user targeting, use a flag service. If you just need to decouple deploy from release and have a kill switch, env vars or a simple config system are often enough and significantly simpler to operate.

Where the Complexity Hides

Combinatorial explosion. Ten flags that interact with each other create up to 2^10 = 1,024 possible states. In practice you test 5 of them. The untested 1,019 combinations include the one a customer hits at 2am on a Friday.

A flag that covers a single feature doesn’t multiply with other flags. A flag that changes a shared data model does. The more central the code the flag touches, the more combinations it creates.

The dead “off” path. Once a flag is enabled for 100% of users, the “off” branch is no longer tested. After a few weeks it’s no longer accurate - the “on” path makes assumptions the “off” path doesn’t satisfy. The branches diverge. The flag is supposed to be a safety net, but the net has holes.

Zombie flags. A flag that should have been removed after launch but wasn’t. It’s referenced in 8 files. Nobody knows if it’s safe to remove. Nobody wants to own the cleanup. It stays forever, accumulating around it more conditionals that reference it “just in case.”

Context leakage. The flag is checked once at request entry, but the result gets passed down through the call stack as a parameter. Three levels deep, you’re passing use_new_checkout into functions that shouldn’t know about it. The flag’s existence pollutes the function signatures.

The Lifecycle Problem

Most flag-related problems are lifecycle problems, not implementation problems. The flag is created with clear intent. Then:

  • The intended cleanup date passes without anyone removing it
  • The engineer who owned it leaves the team
  • Other engineers add conditionals that assume the flag exists
  • The “off” path accumulates untested drift
  • Nobody’s sure what removing it would break

Fix: treat the cleanup date as a hard constraint, not a nice-to-have.

When creating a flag, decide at creation time:

  • What the cleanup trigger is (“enabled for 100% for two releases” or “A/B test concluded”)
  • Who owns cleanup
  • What “fully removed” looks like (not just deleting the flag in the service, but removing the conditional from the code)

A naming convention that includes the expiry context helps: new-checkout-q3-2026 makes the flag’s purpose and timeline clear without opening the code.

What Actually Matters in Practice

The kill switch use case is the highest-value application of feature flags. If a payment integration starts failing, being able to disable it instantly - before you’ve had time to diagnose and deploy a fix - is worth significant engineering investment. This use case justifies a flag service for any team with a revenue-critical path.

The gradual rollout use case requires a flag service with percentage targeting. The value is proportional to the risk of the feature being rolled out. Rolling out a tooltip change with a gradual rollout is overkill. Rolling out a new pricing model is exactly what gradual rollout is for.

The A/B testing use case has different requirements from the other two: you need metric collection, statistical analysis, and the ability to target specific user segments with randomized assignment. A flag service technically supports this, but purpose-built A/B tools (Optimizely, Split) are better suited.

When not to use a feature flag:

  • Features that will never need to be disabled (just deploy them)
  • Permanent configuration differences by user tier (use proper entitlement logic, not flags)
  • Infrastructure configuration (use infrastructure variables, not application flags)
  • Anything that stays toggled for more than a quarter without a specific reason

Flags that never get removed are code that never gets cleaned up. The rule: if you can’t name the trigger that will cause the flag to be removed, don’t add it.



Read more