Ship Safely, Roll Back Instantly
Feature flags decouple deployment from release. You deploy code to production with the feature hidden behind a flag. Then you turn it on for 1% of users, watch your metrics, and gradually increase to 100%. If something breaks, you flip the flag off. No rollback deploy, no downtime, no emergency hotfix.
That's the pitch, anyway. In practice, feature flags introduce their own complexity — flag management, testing combinatorics, technical debt from old flags. Let's talk about what works and what doesn't.
Types of Feature Flags
Not all flags are the same, and conflating them causes problems.
Release flags are temporary. They exist during the rollout period and should be removed once the feature is fully launched. Lifespan: days to weeks.
Experiment flags control A/B tests. They route users into test groups for data collection. Lifespan: weeks to months. They need consistent assignment — the same user should always see the same variant.
Ops flags are kill switches. They let you disable expensive features during incidents. "Turn off recommendation engine when the ML service is overloaded." These are long-lived and should stay in the codebase permanently.
Permission flags control access by user segment. "Enterprise customers get the analytics dashboard." These are really authorization, and I'd argue they belong in your permissions system, not your feature flag system. But many teams put them in flags because the tooling is convenient.
Implementation Approaches
Simple File-Based Flags
For small teams, a configuration file might be all you need:
# feature_flags.py
FLAGS = {
"new_checkout_flow": {
"enabled": True,
"rollout_percentage": 25,
"allowed_users": ["internal-team@company.com"],
},
"disable_recommendations": {
"enabled": False,
"type": "ops",
},
}
def is_enabled(flag_name, user_id=None):
flag = FLAGS.get(flag_name)
if not flag or not flag["enabled"]:
return False
# Check user allowlist
if user_id and user_id in flag.get("allowed_users", []):
return True
# Percentage rollout (deterministic per user)
pct = flag.get("rollout_percentage", 100)
if pct < 100 and user_id:
# Hash user_id to get consistent assignment
user_hash = int(hashlib.md5(
f"{flag_name}:{user_id}".encode()
).hexdigest(), 16)
return (user_hash % 100) < pct
return True
This works until you need to change flags without deploying. Which you will, probably within the first week of using flags. That's when you need a proper flag management system.
Database-Backed Flags
Store flag configurations in your database with a simple admin UI. Changes take effect within your cache TTL (say 30 seconds). This is the sweet spot for many mid-size teams — you get dynamic control without adding another external service.
Dedicated Feature Flag Services
LaunchDarkly is the market leader, but at $10-20 per seat per month, it gets expensive for larger teams. Here are alternatives worth considering:
- Unleash — open-source, self-hosted. Solid feature set including gradual rollouts, A/B testing, and SDKs for major languages. Runs on PostgreSQL. I've used this in production and it handles 10,000+ flag evaluations per second without issues.
- Flagsmith — open-source with a hosted option. Similar to Unleash but with a nicer UI and built-in remote config (key-value pairs that aren't boolean flags).
- GrowthBook — open-source, focused on experimentation and A/B testing. If your primary use case is experiments rather than release management, this might be the better fit.
- OpenFeature — not a flag service itself, but a vendor-neutral SDK specification. Write your code against the OpenFeature API, and swap the backend provider (LaunchDarkly, Flagsmith, custom) without changing application code.
Gradual Rollouts
The value of feature flags comes from gradual exposure. Don't go from 0% to 100%. Here's a rollout schedule that's worked well for me:
Step 1 — Internal team (dogfooding): Enable for your team using an email allowlist. Catch the obvious bugs.
Step 2 — 1% of production users: A small percentage hitting real traffic patterns. Monitor error rates, latency, and business metrics for 24-48 hours.
Step 3 — 10%: Enough traffic to catch less common edge cases. Watch for database load changes and downstream service impact.
Step 4 — 50%: Half your traffic sees the new feature. This is where A/B comparison data becomes statistically significant.
Step 5 — 100%: Full rollout. Keep the flag in place for a few days as a kill switch, then remove it.
Consistent Assignment
For percentage-based rollouts, the same user must always see the same variant. Don't use random() — use a deterministic hash of the user ID and flag name. This ensures consistency across requests, servers, and sessions.
The hash approach also means users gradually "enter" the rollout as you increase the percentage. Going from 10% to 25% adds new users without changing the experience for the existing 10%.
Kill Switches
Every external dependency should have a kill switch. Your recommendation engine, third-party payment processor, email service, analytics pipeline — all of them can fail, and when they do, you want to disable the integration with a flag flip rather than an emergency deploy.
def get_recommendations(user_id):
if not feature_flags.is_enabled("recommendations_enabled"):
return get_fallback_recommendations() # Static/cached results
try:
return ml_service.recommend(user_id, timeout=2.0)
except (Timeout, ServiceUnavailable):
# Auto-disable after repeated failures
feature_flags.auto_disable(
"recommendations_enabled",
reason="ML service timeout",
duration_minutes=10
)
return get_fallback_recommendations()
The auto-disable pattern is powerful — if the ML service times out 50 times in a minute, automatically flip the kill switch and try again in 10 minutes. This gives you circuit-breaker-like behavior controlled through your flag system.
The Technical Debt Problem
Here's the part nobody talks about in the feature flag sales pitch: old flags accumulate. Six months from now, your codebase has 47 flags, half of which are fully rolled out but never removed. The code is littered with if feature_enabled("thing_from_march") branches that are always true.
Enforce flag hygiene:
- Set an expiration date when creating a flag. After that date, a CI check fails if the flag still exists in code.
- Track flag age in your dashboard. Anything older than 30 days that's at 100% rollout should be cleaned up.
- Make flag removal a definition-of-done item. The feature isn't "shipped" until the flag code paths are cleaned up.
- Limit total active flags. I'd suggest a soft limit of 20-30. Beyond that, the testing matrix becomes unmanageable — theoretically you'd need to test 2^N combinations of N boolean flags.
In practice, I've seen teams accumulate hundreds of stale flags over a year. The cleanup cost compounds — removing a flag that's been in the codebase for 6 months is harder than removing one that's been there for 2 weeks, because more code has been written assuming the flag is always on. Clean up early and often.