Feature Flags: Stale Flag Causes 15-Minute Outage
A stale flag at 100% for 3 months caused a NullPointerException, taking down checkout for 10% of users.
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of DevOps fundamentals
- ✓Comfortable with command-line tools
- ✓Basic Linux administration knowledge
- A feature flag is a conditional in your code that controls whether a feature is active.
- Deploy code with the feature off, then turn it on without a new deployment.
- Flags enable canary releases, kill switches, A/B tests, and trunk-based development.
- Use percentage rollouts with consistent hashing to ensure same user gets same experience.
- Flag evaluation latency adds ~1-5ms per check; batch evaluations or use SDK caching to stay under 2ms.
- Flag debt from unused conditionals is a real maintenance trap — set TTLs and schedule removals.
Feature flags (also called feature toggles) are conditional branches in code that let you turn functionality on or off at runtime without deploying. They exist to decouple deployment from release — you can ship code to production that's dark, then enable it when you're ready.
This solves the fundamental problem that deploying and releasing are not the same thing. Without flags, you're stuck doing big-bang releases or relying on environment-specific branches, both of which increase risk and coordination overhead. The tradeoff is that every flag you add is a permanent complexity tax on your codebase, and stale flags left in production are a leading cause of outages — exactly the scenario this article covers.
In practice, feature flags range from simple boolean checks in an if-statement (backed by environment variables or a config file) to full-blown managed services like LaunchDarkly, Split, or Unleash that provide targeting, gradual rollouts, and real-time toggling. The basic pattern is always the same: read a flag value from some source, then branch on it.
The sophistication comes from how you manage flag state, targeting rules, and — critically — flag lifecycle. A flag that's been on for everyone for six months is not a feature flag anymore; it's dead code waiting to cause a production incident when someone removes the wrong branch.
The ecosystem includes open-source libraries (FFLAGS, Unleash client SDKs) and SaaS platforms that handle flag evaluation at scale. You should NOT use feature flags for every minor UI tweak — that creates flag debt. They're best reserved for risky changes, gradual rollouts, kill switches, and permission gating.
The canonical failure mode is the one described in this article: a stale flag that was supposed to be temporary, left in production, and accidentally toggled off during a routine config change, causing a 15-minute outage. The fix isn't better flag technology — it's disciplined cleanup and treating flags as ephemeral infrastructure.
Think of a feature flag like a temporary detour sign on a road. It's useful while construction is happening, but if crews forget to remove it, drivers get confused and accidents happen. In software, those forgotten 'signs' are leftover code branches that can crash your application when someone flips the wrong switch.
A stale feature flag left at 100% for three months caused a NullPointerException that took down checkout for 10% of users for 15 minutes. This incident illustrates why feature flags are ephemeral infrastructure, not permanent configuration. The problem isn't the technology—it's the discipline of removing flags once they outlive their purpose.
Why Feature Flags Are a Double-Edged Sword
Feature flags (also called toggles) are conditional branches in code that allow you to turn functionality on or off without deploying new code. The core mechanic is simple: a boolean check at runtime reads a flag value from a configuration source — environment variable, database, or dedicated service — and gates execution accordingly. This decouples deployment from release, letting you ship incomplete features to production safely.
In practice, flags introduce a persistent state dependency into your application. Every flag evaluation adds latency (typically <1ms for local config, 5-50ms for remote evaluation) and, more critically, creates combinatorial complexity. With N flags, you have 2^N possible system states. Teams often forget to remove flags after a rollout completes, leaving dead code paths that accumulate technical debt and obscure the actual control flow.
Use feature flags for canary releases, A/B testing, and kill switches — not for permanent configuration. The real value is in reducing deployment risk: you can roll back a bad feature by flipping a flag instead of reverting a deploy. But every flag you add is a liability. The industry rule of thumb: if a flag has been in production for more than two release cycles without being removed, it's already stale.
Basic Flag Implementation
The simplest feature flag is just an if statement controlled by an environment variable or a config value. This pattern works for small teams and simple rollouts. For a production grade approach, you need consistent user bucketing — the same user must always see the same experience. A common way is to hash the flag name with the user ID and take modulo 100 to assign a bucket.
Here's the minimal pattern in Python, using the io.thecodeforge namespace for all production packages.
# Package: io.thecodeforge.python.devops # Simplest possible feature flag — environment variable import os def get_recommendations(user_id: int): if os.getenv('ENABLE_ML_RECOMMENDATIONS', 'false') == 'true': return ml_recommendations(user_id) # new ML-based system else: return rule_based_recommendations(user_id) # old system # Better: percentage rollout — test on a fraction of users import hashlib def is_flag_enabled(flag_name: str, user_id: int, percentage: float) -> bool: """Consistently assign users to buckets using hash — same user always gets same result.""" hash_input = f'{flag_name}:{user_id}'.encode() hash_val = int(hashlib.md5(hash_input).hexdigest(), 16) bucket = (hash_val % 100) + 1 # 1-100 return bucket <= percentage # Roll out to 5% of users def get_checkout_flow(user_id: int): if is_flag_enabled('new_checkout', user_id, 5.0): return new_checkout_flow(user_id) return old_checkout_flow(user_id)
Feature Flag Service — LaunchDarkly SDK Pattern
When your team needs targeting by user attributes (plan, country, beta group), a dedicated flag service is the way to go. The SDK handles evaluation, caching, and streaming updates. This example shows how to use the LaunchDarkly SDK in Python, evaluating a flag with a rich user context.
# Package: io.thecodeforge.python.devops # Using a feature flag service (LaunchDarkly, Unleash, Flagsmith) import ldclient from ldclient.config import Config ldclient.set_config(Config(sdk_key='your-sdk-key')) client = ldclient.get() # Evaluate a flag for a specific user def get_dashboard(user): context = { 'key': str(user.id), 'name': user.name, 'email': user.email, 'plan': user.subscription_plan, # target premium users 'country': user.country # GDPR rollout by country } # Flag evaluated with user context — targeting rules in dashboard if client.variation('new-dashboard-v2', context, default=False): return render_new_dashboard(user) return render_old_dashboard(user)
default parameter in client.variation() is critical. If the flag service is unreachable (network partition, service down), the SDK falls back to this default. Always default to the old/safe behavior — never default to enabling a new feature.Types of Feature Flags
Not all feature flags are the same. Pete Hodgson's taxonomy (from Martin Fowler's article) defines four types: release toggles, experiment toggles, ops toggles, and permission toggles. Release toggles are short-lived — they control rollout of a new feature. Experiment toggles are for A/B tests and should be removed after the experiment ends. Ops toggles are kill switches and circuit breakers — they must be fast and reliable. Permission toggles (entitlement flags) enable features for specific user segments (e.g., premium plan users) and can live long-term.
Mixing these types leads to confusion. Use naming conventions to distinguish: release_, exp_, ops_, perm_.
# Package: io.thecodeforge.python.devops # Naming convention for flag types release_flag_variation = client.variation('release_new_checkout_v3', context, default=False) experiment_flag_variation = client.variation('exp_checkout_button_color', context, default='blue') ops_flag_variation = client.variation('ops_disable_payment_gateway', context, default=False) perm_flag_variation = client.variation('perm_premium_dashboard', context, default=False)
- Release flags: live 1 day – 2 weeks. Remove once rollout reaches 100%.
- Experiment flags: live for the duration of the experiment (days to months). Remove after analysis.
- Ops flags: live indefinitely but must be easy to toggle and have monitoring.
- Permission flags: live indefinitely, but should be managed by a product config system, not a feature flag tool.
Canary Releases and Gradual Rollout with Flags
Canary releases are about routing a percentage of traffic to a new version of the service at the infrastructure level (e.g., Kubernetes canary deployments). But feature flags can enhance canaries by allowing you to target specific user segments within the canary pod. For example, you deploy the new version to 5% of pods, then use a feature flag to only enable the new feature for 10% of users hitting those pods. This gives you fine-grained control.
This pattern is common at large scale: you canary the deployment at the pod level, and inside the pod, use a flag to limit exposure further. This reduces blast radius if the new version has a bug — only a subset of the canary group sees the broken code.
# Package: io.thecodeforge.python.devops # Canary with feature flag: even if the pod receives traffic, only a fraction of users get the new feature import hashlib def compute_bucket(user_id, flag_name, total_percent): hash_val = int(hashlib.md5(f'{flag_name}:{user_id}'.encode()).hexdigest(), 16) return (hash_val % 100) + 1 <= total_percent # Canary: 5% of pods run new code, but only 20% of users on those pods get the feature # That's effectively 1% of total users if compute_bucket(user_id, 'new_recommendation_v2', 20): # This code only runs in the canary pods return new_recommendation_system(user_id) else: return old_system(user_id)
Managing Flag Debt and Cleanup
Flag debt is the accumulation of stale conditionals in your code. Every flag that is no longer needed but still present forces your team to maintain two paths. Over time, the old path can break silently because it's rarely tested. The solution is to make flags ephemeral: set a removal date when you create the flag, automate reminders, and schedule cleanup as part of your sprint cycle.
A good rule: if a release flag has been at 100% for more than two weeks, it must be removed. For experiment flags, remove after the experiment analysis is complete — don't keep them 'just in case'. Ops flags and permission flags are exceptions, but they should be reviewed quarterly.
# Package: io.thecodeforge.python.devops # Example: automate flag cleanup detection in CI # This would be a script that checks git for old flag references import subprocess import re FLAG_PATTERN = r'client\.variation\([\'"]([\w-]+)[\'"]' def find_old_flags(months: int = 3): # Get all flags used in codebase result = subprocess.run(['grep', '-roPh', FLAG_PATTERN, 'src/'], capture_output=True, text=True) flags = set(re.findall(FLAG_PATTERN, result.stdout)) # Check each flag's metadata (would use API in real life) # For now, just list them return flags # In CI, warn if a release flag is older than 2 weeks # This helps reduce flag debt
client.variation() call for a flag that is > 2 weeks at 100% rollout.Flag-Driven Development Is a Testing Trap Without Kill Switches
Most teams add feature flags for gradual rollouts but forget the most critical flag: the kill switch. A kill switch is a flag that disables an entire feature category instantly — no dashboard login, no targeting rule tweak, just off. Without one, a broken feature that passes canary at 5% might kill your p99 latency at 25%. I've seen teams scramble to redeploy because their feature flags only controlled visibility, not execution. Kill switches live at the infrastructure level — environment variables or static configs loaded at startup — not in your feature management SDK. They should be toggleable from your CI/CD pipeline or a simple file change, not a slow API call. Netflix calls this the 'circuit breaker for features.' You need it. Because when your new checkout flow accidentally charges customers twice, you don't want to debug targeting rules — you want that code path dead in 10 seconds.
# io.thecodeforge.feature-flags.kill-switch import os KILL_SWITCH_CHECKOUT = os.getenv("KILL_SWITCH_CHECKOUT", "false").lower() == "true" def process_checkout(user, cart): if KILL_SWITCH_CHECKOUT: logger.warning("Checkout kill switch active — falling back to legacy path") return legacy_checkout(user, cart) if feature_flags.is_enabled("new-checkout"): return new_checkout(user, cart) return legacy_checkout(user, cart)
Feature Flags Don't Replace Contract Tests — They Expose Missing Ones
Teams often think feature flags let them skip contract testing because they can 'just turn it off' if something breaks. That's dangerously wrong. A flag hides the UI or the code path, but your services still need to handle the new data shapes, the new API responses, the new database schema. I've watched a team spend two hours rolling back a flag-enabled feature because the old checkout service started receiving new payloads from a misconfigured flag that targeted the wrong user segment. The fix isn't more flags — it's consumer-driven contract tests (CDCTs) that validate both branches of every flag. Write Pact tests that verify the old path behaves correctly when the flag is off AND the new path when the flag is on. Every time you add a flag, you double your testing surface. Cover it with contracts, not hope.
# io.thecodeforge.feature-flags.contract-tests from pact import Consumer, Provider consumer = Consumer('CheckoutFrontend').has_pact_with(Provider('CheckoutService')) # Test both flag states @consumer.given('new-checkout flag is off') def test_legacy_contract(): expected = {'total': 29.99, 'items': [...]} pact = consumer.upon_receiving('legacy checkout request').with_request('POST', '/checkout', body={'cart_id': 'abc'}) pact.will_respond_with(200, body=expected) with pact: result = request_checkout({'cart_id': 'abc', 'flag_override': False}) assert result == expected @consumer.given('new-checkout flag is on') def test_new_contract(): expected = {'total': 29.99, 'items': [...], 'promo_applied': True} pact = consumer.upon_receiving('new checkout request').with_request('POST', '/checkout', body={'cart_id': 'abc'}) pact.will_respond_with(200, body=expected) with pact: result = request_checkout({'cart_id': 'abc', 'flag_override': True}) assert result == expected
Flag That Never Died: A Stale Flag Causes a 15-Minute Production Outage
- Short-lived flags must die on a schedule — never let a rollout flag live past 2 weeks at 100%.
- Flag evaluation should never throw: always provide a safe default and catch evaluation errors gracefully.
- Monitor flag usage: alert when a flag has been at 100% for more than 14 days.
`curl -X GET "https://flags.example.com/eval?flag=my-feature&user=user123"``kubectl logs pod/my-app-pod | grep "flag_eval" | tail -50`export MY_FEATURE_FLAG=false and restart the pod.`kill -HUP $(pgrep my-app)` (if the app reloads flags on SIGHUP)`kubectl rollout restart deployment/my-app``curl -w "@%{time_total}\n" -o /dev/null -s "https://myapp.com/api/checkout"``jstack $(pgrep -f my-app) | grep "FlagClient"`| Type | Lifecycle | Example | Removal Policy |
|---|---|---|---|
| Release Toggle | Short-lived (days to weeks) | Deploy new checkout flow | Remove at 100% rollout + 2 weeks |
| Experiment Toggle | Medium-lived (days to months) | A/B test button color | Remove after experiment analysis |
| Ops Toggle | Long-lived (indefinite) | Kill switch for payment gateway | Review quarterly, monitor usage |
| Permission Toggle | Long-lived (indefinite) | Show premium feature | Use RBAC instead if possible |
| File | Command / Code | Purpose |
|---|---|---|
| def get_recommendations(user_id: int): | Basic Flag Implementation | |
| from ldclient.config import Config | Feature Flag Service | |
| release_flag_variation = client.variation('release_new_checkout_v3', context, de... | Types of Feature Flags | |
| def compute_bucket(user_id, flag_name, total_percent): | Canary Releases and Gradual Rollout with Flags | |
| FLAG_PATTERN = r'client\.variation\([\'"]([\w-]+)[\'"]' | Managing Flag Debt and Cleanup | |
| kill_switch.py | KILL_SWITCH_CHECKOUT = os.getenv("KILL_SWITCH_CHECKOUT", "false").lower() == "tr... | Flag-Driven Development Is a Testing Trap Without Kill Switc |
| contract_test.py | from pact import Consumer, Provider | Feature Flags Don't Replace Contract Tests |
Key takeaways
Common mistakes to avoid
4 patternsUsing environment variables for hundreds of flags
Not providing a safe default in the evaluation call
True, your new feature becomes enabled for everyone — potentially exposing unstable code or causing a crash.client.variation('flag-key', context, default=False) where False means old code path.Evaluating flags inside loops or hot code paths
Keeping experiment flags after analysis is complete
Interview Questions on This Topic
What is a feature flag and what problems does it solve?
How do you ensure a user consistently gets the same experience with a percentage rollout flag?
What is flag debt?
Describe the four types of feature flags and when to use each.
Frequently Asked Questions
A canary release routes a percentage of traffic to a new deployment at the infrastructure level (load balancer rules, Kubernetes traffic splitting). A feature flag controls feature visibility at the application level within a single deployment. Feature flags are more granular — you can target specific users, plans, or countries. Often both are used together.
Flag debt accumulates when flags are never removed after their purpose is served — code becomes littered with old conditionals. Manage it by setting a TTL when creating a flag, adding JIRA tickets to clean up flags after rollout, and doing periodic flag audits. A good rule: any flag that has been at 100% rollout for more than 2 weeks should be removed.
Yes, but it's usually small (<5ms) when using a local cache. With a dedicated SDK and streaming updates, evaluation time is often under 1ms. The bigger risk is evaluating flags in loops or hot paths — that can add significant latency. Always benchmark your flag evaluation in production under load.
For a small team with fewer than 5 flags, environment variables are fine. But as soon as you need targeting, gradual rollout, A/B testing, or audit trails, move to a dedicated service. Popular choices: LaunchDarkly (SaaS), Unleash (open-source), Flagsmith, or custom-built solutions. The overhead is minimal and the benefits are substantial.
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
That's CI/CD. Mark it forged?
4 min read · try the examples if you haven't