| Flag Type | Complexity | Typical Lifetime | Use Case | Cleanup |
|---|---|---|---|---|
| Release Toggle | Low | Days to weeks | Gradually roll out a new feature to users | Remove after 100% rollout confirmed stable |
| Experiment Toggle | Medium | Weeks to months | A/B test two variants and measure outcomes | Remove after experiment concludes and winner ships |
| Ops Toggle | Low | Permanent or long-lived | Circuit breaker, graceful degradation under load | Keep as long as operational risk exists |
| Permission Toggle | Medium | Months to years | Gate premium features by user tier/plan | Keep for lifetime of pricing model |
| Kill Switch | Low | Permanent | Emergency disable of non-critical features under load | Keep permanently -- safety mechanism |
| Canary Flag | Medium | Hours to days | Route 1-5% of traffic to new code path | Remove after canary period passes |
| Trunk-Based Dev Flag | Low | Days | Hide incomplete code on main branch | Remove when feature is complete |
| Migration Flag | Medium | Weeks | Switch between old and new data path | Remove after migration verified |
[src1]
START
|-- Need to gradually roll out a new feature?
| |-- YES --> Use a Release Toggle with percentage rollout (see Step 2)
| +-- NO |
|-- Need to run an experiment (A/B test)?
| |-- YES --> Use an Experiment Toggle with user segmentation
| +-- NO |
|-- Need emergency disable capability?
| |-- YES --> Use a Kill Switch (permanent, simple boolean)
| +-- NO |
|-- Need to gate features by user plan/tier?
| |-- YES --> Use a Permission Toggle (long-lived, user-attribute based)
| +-- NO |
|-- Need to degrade non-critical services under load?
| |-- YES --> Use an Ops Toggle (circuit breaker pattern)
| +-- NO |
+-- DEFAULT --> Use a Release Toggle for trunk-based development
Create a clean abstraction so your business logic does not depend on a specific flag provider. This lets you swap between custom implementations and managed services. [src2]
// flag-service.ts -- minimal interface for flag evaluation
interface FlagService {
isEnabled(flagKey: string, context?: FlagContext): boolean;
getVariant(flagKey: string, context?: FlagContext): string;
}
interface FlagContext {
userId?: string;
email?: string;
country?: string;
plan?: string;
percentageSeed?: number; // stable hash for consistent bucketing
}
Verify: Ensure your interface supports boolean flags and multi-variant flags with user context.
Use consistent hashing so the same user always sees the same variant. Never use Math.random() -- it causes flickering between page loads. [src7]
// percentage-rollout.ts
import { createHash } from 'crypto';
function isEnabledForPercentage(
flagKey: string, userId: string, percentage: number
): boolean {
const hash = createHash('md5')
.update(`${flagKey}:${userId}`).digest('hex');
const bucket = parseInt(hash.substring(0, 8), 16) % 100;
return bucket < percentage;
}
Verify: Call isEnabledForPercentage('flag', 'user-123', 50) multiple times -- it must return the same value every time.
Layer targeting rules on top of percentage rollout for fine-grained control. Evaluate rules in priority order: explicit overrides first, then segments, then percentage. [src5]
function evaluateFlag(config: FlagConfig, ctx: FlagContext): boolean {
if (!config.enabled) return false;
if (ctx.userId && ctx.userId in config.overrides) {
return config.overrides[ctx.userId];
}
for (const rule of config.rules) {
if (matchesRule(rule, ctx)) return rule.enabled;
}
if (ctx.userId) {
return isEnabledForPercentage(config.key, ctx.userId, config.percentage);
}
return false;
}
Verify: Test with an overridden user, a rule-matched user, and a percentage-only user.
Set expiration dates at creation time and enforce them in CI. This is the most important step -- skipping it guarantees flag debt. [src4]
function checkExpiredFlags(registry: FlagRegistration[]): string[] {
const now = new Date();
return registry
.filter(f => f.expiresAt && f.expiresAt < now)
.map(f => `EXPIRED: ${f.key} (owner: ${f.owner})`);
}
// CI pipeline: fail build if checkExpiredFlags() returns non-empty
Verify: checkExpiredFlags(registry) should return an empty array in a healthy codebase.
// Input: Redis connection URL, flag configs stored as JSON
// Output: Boolean flag evaluation with percentage rollout
const Redis = require('ioredis'); // [email protected]
const crypto = require('crypto');
class FeatureFlagService {
constructor(redisUrl) {
this.redis = new Redis(redisUrl);
this.cache = new Map(); // local cache, 30s TTL
}
async isEnabled(flagKey, userId, fallback = false) {
try {
const config = await this.getConfig(flagKey);
if (!config || !config.enabled) return false;
if (config.overrides?.[userId] !== undefined) {
return config.overrides[userId];
}
if (config.percentage < 100) {
const hash = crypto.createHash('md5')
.update(`${flagKey}:${userId}`).digest('hex');
return (parseInt(hash.slice(0, 8), 16) % 100) < config.percentage;
}
return true;
} catch (err) {
return fallback; // always return fallback on error
}
}
}
# Input: PostgreSQL connection, flag configs in flags table
# Output: Boolean flag evaluation with user targeting
import hashlib
from typing import Optional
import psycopg2 # psycopg2-binary==2.9.x
class FeatureFlagService:
def __init__(self, dsn: str):
self.conn = psycopg2.connect(dsn)
def is_enabled(self, flag_key: str, user_id: str, fallback: bool = False) -> bool:
try:
config = self._get_config(flag_key)
if not config or not config["enabled"]:
return False
if user_id in config.get("overrides", {}):
return config["overrides"][user_id]
pct = config.get("percentage", 100)
if pct < 100:
h = hashlib.md5(f"{flag_key}:{user_id}".encode()).hexdigest()
return int(h[:8], 16) % 100 < pct
return True
except Exception:
return fallback
// Input: Flag config map, HTTP request with user context
// Output: HTTP middleware that injects flag values into request context
func (fs *FlagService) IsEnabled(flagKey, userID string) bool {
config, ok := fs.flags[flagKey]
if !ok || !config.Enabled { return false }
if val, exists := config.Overrides[userID]; exists { return val }
if config.Percentage < 100 {
hash := md5.Sum([]byte(flagKey + ":" + userID))
bucket := binary.BigEndian.Uint32(hash[:4]) % 100
return int(bucket) < config.Percentage
}
return true
}
// Input: Unleash server URL, API token
// Output: Feature flag evaluation via managed service
const { initialize } = require('unleash-client'); // [email protected]
const unleash = initialize({
url: 'https://unleash.example.com/api',
appName: 'my-app',
customHeaders: { Authorization: process.env.UNLEASH_API_KEY },
refreshInterval: 15000,
});
unleash.on('ready', () => {
const context = { userId: user.id, properties: { plan: user.plan } };
if (unleash.isEnabled('premium-feature', context)) {
enablePremiumFeature();
}
});
// BAD -- flag check buried inside business logic
function processOrder(order) {
validateItems(order.items);
if (featureFlags.isEnabled('new-shipping')) { // buried deep
applyNewShipping(order);
if (featureFlags.isEnabled('new-tracking')) { // nested flags!
enableTracking(order);
}
} else { applyOldShipping(order); }
}
// GOOD -- evaluate at the entry point, pass result down
app.post('/orders', (req, res) => {
const features = {
useNewShipping: flags.isEnabled('new-shipping', req.user.id),
};
processOrder(req.body, features);
});
[src1]
// BAD -- flag with no lifecycle management
await redis.set('flag:new-dashboard', JSON.stringify({
enabled: true, percentage: 100,
// No owner, no expiration -- lives forever
}));
// GOOD -- every flag has an owner and expiration
await redis.set('flag:new-dashboard', JSON.stringify({
enabled: true, percentage: 100,
owner: 'frontend-team', createdAt: '2026-02-01',
expiresAt: '2026-03-15', jiraTicket: 'FE-4521', type: 'release',
}));
[src4]
# BAD -- random gives different results on every evaluation
import random
def is_enabled(flag_key, user_id, percentage):
return random.random() * 100 < percentage # flickers every request!
# GOOD -- same user always gets same bucket
import hashlib
def is_enabled(flag_key, user_id, percentage):
h = hashlib.md5(f"{flag_key}:{user_id}".encode()).hexdigest()
return int(h[:8], 16) % 100 < percentage # deterministic per user
[src7]
// BAD -- flag B depends on flag A, but no explicit relationship
if (flags.isEnabled('new-api') && flags.isEnabled('new-api-v2')) {
// Undefined behavior if new-api is off but new-api-v2 is on
}
// GOOD -- declare dependencies in flag config
const flagConfig = {
'new-api-v2': {
enabled: true, dependsOn: ['new-api'], percentage: 50,
},
};
[src2]
md5(flagKey + userId) for consistent bucketing. [src7]ALL_FLAGS=on and ALL_FLAGS=off matrix. [src2]# List all feature flags in a Redis-backed system
redis-cli KEYS "flag:*" | while read key; do echo "$key: $(redis-cli GET $key)"; done
# Find all flag references in codebase (grep for cleanup)
grep -rn "isEnabled\|is_enabled\|IsEnabled\|feature_flag" --include="*.ts" --include="*.py" --include="*.go" src/
# Check for expired flags in a JSON config file
cat flags.json | python3 -c "
import json, sys
from datetime import datetime
flags = json.load(sys.stdin)
now = datetime.now().isoformat()[:10]
for k, v in flags.items():
exp = v.get('expiresAt')
if exp and exp < now:
print(f'EXPIRED: {k} (expired {exp}, owner: {v.get(\"owner\", \"unknown\")})')
"
| Technology | Status | Key Feature | Notes |
|---|---|---|---|
| OpenFeature v0.7 | Current spec (2024) | Vendor-neutral SDK interface | Adopt for new projects to avoid vendor lock-in |
| LaunchDarkly SDK v8 | Current | Streaming updates, local evaluation | Industry leader. Free tier: 1K MAU |
| Unleash v5 | Current | Self-hosted OSS, Prometheus metrics | Best open-source option. Pro tier for RBAC |
| Flagsmith v2 | Current | Open-source, edge evaluation | Good for self-hosted with edge requirements |
| GrowthBook v2 | Current | Statistics engine built-in | Best when A/B testing is primary use case |
| Custom (DB/Redis) | Evergreen | Full control, no vendor dependency | Good for <50 flags, becomes painful at scale |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Gradually rolling out features to reduce blast radius | Toggling between dev/staging/prod environments | Environment variables or config management |
| Running A/B experiments on UI or backend behavior | Permanently gating features by user role/plan | RBAC or entitlement system |
| Need emergency kill switch for non-critical services | Configuring infrastructure (ports, URLs, timeouts) | Infrastructure-as-code (Terraform, Helm) |
| Hiding incomplete features on main branch (trunk-based dev) | Simple on/off that never changes after deploy | Hard-code or compile-time flag |
| Canary releases -- test with 1% traffic before full rollout | Fewer than 3 developers or no CI/CD pipeline | Ship directly, iterate fast |
| Decoupling deploy from release in regulated environments | Feature is security-critical (auth, encryption) | Code review + staged deployment instead |