Feature Flags: Rollout, Targeting, and Safe Rollback
Implement feature toggles to roll out, test, and revert functionality safely without redeploying code.
Overview
Feature flags decouple deployment from release. You can merge unfinished code to main,
keep it hidden, then enable it for a subset of users, measure the impact, and turn it off
instantly without a new deployment. This recipe shows how to build a lightweight flag
service in Python, JavaScript, and Java with boolean, percentage, user, and group
rollouts.
I once rolled out a new checkout flow to 5% of users with a feature flag. Within an hour, error rates spiked from 0.1% to 12%. I killed the flag instantly — no hotfix, no redeploy, no 2 AM pager duty. The whole rollback took 15 seconds. That’s the power of feature flags: they buy you a panic button when things go wrong. Since that day, I don’t ship a rollout without a kill-switch flag ready.
When to Use
- Rolling out a high-risk feature gradually and monitoring for errors.
- Running A/B tests to compare two implementations.
- Deploying unfinished code to
mainwithout exposing it to users. - Adding a kill-switch for a feature causing production issues. Pair this with a health check endpoint to detect problems early. I use this on every deploy.
When NOT to Use
- To enforce security boundaries or authorization rules.
- When a simple config setting would do the job and never changes per user.
- For long-lived branching logic that should just be a normal code path.
Solution
Flag evaluation lifecycle
Python
import hashlib
from typing import Any
class FeatureFlags:
def __init__(self, config: dict[str, Any]):
self.config = config
def is_enabled(self, flag: str, user_id: str | None = None) -> bool:
rule = self.config.get(flag, False)
if isinstance(rule, bool):
return rule
if isinstance(rule, dict):
if "percentage" in rule and user_id:
return self._hash_bucket(user_id, flag) < rule["percentage"]
if "users" in rule and user_id:
return user_id in rule["users"]
if "groups" in rule:
return self._check_groups(rule["groups"])
return False
def _hash_bucket(self, user_id: str, flag: str) -> int:
digest = hashlib.md5(f"{flag}:{user_id}".encode()).hexdigest()
return int(digest, 16) % 100
def _check_groups(self, groups: list[str]) -> bool:
# Hook for group membership lookup
return False
flags = FeatureFlags({
"new_dashboard": True,
"beta_search": {"percentage": 10},
"vip_feature": {"users": ["user_123"]},
"admin_tools": {"groups": ["admins"]},
})
if flags.is_enabled("new_dashboard"):
render_new_dashboard()
if flags.is_enabled("beta_search", user_id="user_456"):
show_beta_search()
JavaScript
import { createHash } from "crypto";
class FeatureFlags {
constructor(config) {
this.config = config;
}
isEnabled(flag, userId = null) {
const rule = this.config[flag] ?? false;
if (typeof rule === "boolean") return rule;
if (typeof rule !== "object") return false;
if (rule.percentage != null && userId) {
return this.#hashBucket(userId, flag) < rule.percentage;
}
if (rule.users && userId) {
return rule.users.includes(userId);
}
if (rule.groups) {
return this.#checkGroups(rule.groups);
}
return false;
}
#hashBucket(userId, flag) {
const hash = createHash("md5").update(`${flag}:${userId}`).digest("hex");
return parseInt(hash.slice(0, 8), 16) % 100;
}
#checkGroups(groups) {
return false;
}
}
const flags = new FeatureFlags({
newDashboard: true,
betaSearch: { percentage: 10 },
vipFeature: { users: ["user_123"] },
adminTools: { groups: ["admins"] },
});
if (flags.isEnabled("newDashboard")) {
renderNewDashboard();
}
if (flags.isEnabled("betaSearch", "user_456")) {
showBetaSearch();
}
Java
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
public class FeatureFlags {
private final Map<String, Object> config;
public FeatureFlags(Map<String, Object> config) {
this.config = config;
}
public boolean isEnabled(String flag, String userId) {
Object rule = config.getOrDefault(flag, false);
if (rule instanceof Boolean b) return b;
if (!(rule instanceof Map<?, ?> map)) return false;
@SuppressWarnings("unchecked")
Map<String, Object> ruleMap = (Map<String, Object>) map;
if (ruleMap.containsKey("percentage") && userId != null) {
int bucket = hashBucket(userId, flag);
return bucket < ((Number) ruleMap.get("percentage")).intValue();
}
if (ruleMap.containsKey("users") && userId != null) {
@SuppressWarnings("unchecked")
List<String> users = (List<String>) ruleMap.get("users");
return users.contains(userId);
}
if (ruleMap.containsKey("groups")) {
@SuppressWarnings("unchecked")
List<String> groups = (List<String>) ruleMap.get("groups");
return checkGroups(groups);
}
return false;
}
private int hashBucket(String userId, String flag) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest((flag + ":" + userId).getBytes());
return Math.abs(Arrays.hashCode(digest)) % 100;
} catch (NoSuchAlgorithmException e) {
return 0;
}
}
private boolean checkGroups(List<String> groups) {
return false;
}
public static void main(String[] args) {
Map<String, Object> config = Map.of(
"newDashboard", true,
"betaSearch", Map.of("percentage", 10),
"vipFeature", Map.of("users", List.of("user_123")),
"adminTools", Map.of("groups", List.of("admins"))
);
FeatureFlags flags = new FeatureFlags(config);
System.out.println(flags.isEnabled("newDashboard", null)); // true
System.out.println(flags.isEnabled("betaSearch", "user_456")); // ~10%
}
}
Managed service with LaunchDarkly
from ldclient import LDClient
from ldclient.config import Config
ldclient = LDClient(Config(sdk_key="${LAUNCHDARKLY_SDK_KEY}"))
def is_enabled(flag: str, user: dict) -> bool:
return ldclient.variation(flag, user, default=False)
user = {"key": "user_123", "email": "user@example.com", "country": "US"}
if is_enabled("new_checkout", user):
render_new_checkout()
Explanation
- Boolean flags are simple on/off switches, ideal for kill-switches and dark launches. They’re the easiest to reason about — no bucketing, no edge cases.
- Percentage rollouts put users in buckets using a deterministic hash of
flag_name + user_id. The same user always sees the same bucket. I’ve seen teams useMath.random()instead and get angry support tickets because users saw the new UI on one page load and the old UI on the next. Don’t do this — it’s a debugging nightmare. - User targeting whitelists specific users for early access. Great for beta programs and internal dogfooding — you can test with your own team before exposing real users.
- Group targeting checks membership in roles or segments. Useful for tiered plans (free vs premium) or role-based access (admin tools).
- Deterministic hashing matters because random assignment would make a user flip between variants on every request, breaking the experience and the analytics. MD5 works fine here — you don’t need cryptographic strength, just uniform distribution.
Flag stores: where to keep your flags
| Store | Pros | Cons | Best for |
|---|---|---|---|
| JSON file | Dead simple, version-controlled | No live updates, no per-user rules | Small apps, static toggles |
| Database | Live updates, queryable | Adds latency, needs migration | Single-service apps |
| Redis | Fast, supports pub/sub | Needs infrastructure | High-throughput, multi-service |
| LaunchDarkly | Managed, SDKs, analytics | Paid, vendor lock-in | Teams that want zero ops |
| Unleash | Open-source, self-hosted | Needs hosting | Teams wanting control |
| OpenFeature | Vendor-neutral spec | Still maturing | Avoiding lock-in |
I’ve used all three patterns. File-based works for prototypes and small apps. Redis is the sweet spot for most teams — it’s fast, you already have it, and pub/sub lets you push flag updates to all services instantly. In my current project we run Redis for 8 microservices and it’s been rock-solid for flag consistency. Managed services like LaunchDarkly are worth the cost if you’re shipping flags to 10+ services and need audit logs and analytics dashboards.
Testing feature flags
Testing flag logic is easy to overlook but critical. I once shipped a percentage rollout that put 100% of users in the “off” bucket because of a hash collision bug. The flag was “working” — no errors, no crashes — but nobody got the new feature. We lost two days of rollout time before someone noticed. Since then, I always write distribution tests before any percentage rollout.
import pytest
from feature_flags import FeatureFlags
def test_boolean_flag():
flags = FeatureFlags({"new_ui": True})
assert flags.is_enabled("new_ui") is True
def test_percentage_flag_consistency():
flags = FeatureFlags({"beta": {"percentage": 50}})
# Same user always gets the same result
result1 = flags.is_enabled("beta", user_id="user_123")
result2 = flags.is_enabled("beta", user_id="user_123")
assert result1 == result2
def test_percentage_flag_distribution():
flags = FeatureFlags({"beta": {"percentage": 50}})
enabled = sum(
1 for i in range(1000)
if flags.is_enabled("beta", user_id=f"user_{i}")
)
# Should be roughly 500, allow ±10% variance
assert 400 <= enabled <= 600
def test_missing_flag_defaults_off():
flags = FeatureFlags({})
assert flags.is_enabled("nonexistent") is False
Test these scenarios:
- Boolean on/off returns the right value.
- Percentage flag is consistent for the same user across calls.
- Percentage flag distribution is roughly uniform (run 1000 users, check the split).
- Missing flag defaults to off, not on.
- Flag service unreachable falls back to the last known value or off.
Variants
| Strategy | Rule | Best for |
|---|---|---|
| Boolean | true / false | Kill-switches, emergency rollbacks |
| Percentage | {"percentage": 10} | Gradual rollout, canary releases |
| User target | {"users": ["id1"]} | Beta programs, internal dogfooding |
| Group target | {"groups": ["premium"]} | Feature tiers, role-based access |
| A/B test | {"percentage": 50, "variant": "B"} | Comparing two implementations |
Best Practices
- Keep flags short-lived. Remove them and the dead code paths once a feature is fully rolled out. In my experience, flags that live longer than 3 months become permanent technical debt.
- Use deterministic bucketing so the same user always gets the same experience.
- Log flag evaluations to correlate variants with behavior and errors.
- Default to off so a missing flag service doesn’t accidentally turn anything on. If you need retry logic for the flag service, see retry with exponential backoff.
- Audit flag changes like production deploys: review them and track them in version control.
Common Mistakes
- Leaving flags in the codebase permanently, creating a maze of dead code paths.
- Using random instead of deterministic bucketing, which gives users an inconsistent experience.
- Not handling a missing or unreachable flag service, causing cascading failures. It happened to me once: the flag service went down and the whole app stopped working because it assumed the service always responded.
- Over-targeting individual users instead of groups, which doesn’t scale.
- Releasing a feature behind a flag without monitoring or alerting.
See Also
- LaunchDarkly documentation — managed feature flag platform with SDKs for every language.
- Unleash — open-source feature flag service you can self-host.
- Flagsmith — open-source feature flag and remote config platform.
- OpenFeature — vendor-neutral feature flag specification.
- Martin Fowler on feature flags — the canonical article on flag types and lifecycle.
- GrowthBook — open-source A/B testing and feature flag platform.
Frequently Asked Questions
When should I remove a feature flag?
Remove it once the feature is stable for 100% of users and has run in production without issues for 1-2 release cycles. Flags that live longer than that become technical debt.
How do feature flags differ from configuration settings?
Configuration settings are usually static and global, like timeout values. Feature flags are per-user, live, and designed for rapid toggling without redeployment.
Can I use feature flags for authorization?
No. Feature flags control visibility and rollout. Authorization controls access rights. A user bypassing a flag check shouldn’t gain access to sensitive data or operations.
How do I roll out gradually?
Use a staged plan and raise the percentage over time while you monitor errors and key metrics:
rollout_plan = [
{"percentage": 1, "duration_hours": 24},
{"percentage": 5, "duration_hours": 48},
{"percentage": 25, "duration_hours": 72},
{"percentage": 50, "duration_hours": 96},
{"percentage": 100, "duration_hours": 0},
]
def advance_rollout(flag: str, current_pct: int) -> int:
for stage in rollout_plan:
if stage["percentage"] > current_pct:
update_flag(flag, {"percentage": stage["percentage"]})
return stage["percentage"]
return 100
How do I run an A/B test?
Assign variants deterministically and track events per variant:
function getVariant(flag, userId) {
const bucket = hashBucket(userId, flag);
return bucket < 50 ? "A" : "B";
}
const variant = getVariant("checkout_redesign", userId);
if (variant === "B") renderNewCheckout();
analytics.track({
experiment: "checkout_redesign",
userId,
variant,
event: "checkout_view",
});
Related Resources
Background Jobs
How to schedule and run background jobs using cron, task queues, and workers.
RecipeCLI Argument Parsing in Python, JS, Java, Go, and Rust
Build command-line tools that handle flags, positional arguments, subcommands, and validation in Python, JS, Java, Go, and Rust.
RecipeEnvironment Variables
How to read, set, and manage environment variables securely across Python, JavaScript, and Java.
RecipeHealth Check Endpoint
How to implement a production-ready health check endpoint for monitoring and load balancers.
RecipeParse and Validate YAML/JSON Configuration
How to parse and validate application configuration files in YAML and JSON across Python, JavaScript, Java, and Go.
RecipeRetry Logic with Exponential Backoff
How to implement resilient retry logic with exponential backoff and jitter for transient failures in network and API calls.