- Published on
Feature Flags at Scale — Beyond Simple On/Off Toggles
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Feature flags decouple deployment from release, enabling trunk-based development, gradual rollouts, and instant rollbacks. Beyond simple on/off toggles, production systems use five flag types: release (new features), experiment (A/B tests), ops (infrastructure), permission (authorization), and kill switches (emergency stops). This post covers flag types, vendor options, percentage and user-based targeting, flag lifecycle, stale flag detection, and how flags enable trunk-based development without feature branches.
- Flag Types and Decision Tree
- LaunchDarkly vs OpenFeature vs Custom
- Percentage Rollouts
- User Targeting Rules
- Stale Flag Detection and Cleanup
- Trunk-Based Development with Flags
- Feature Flags Checklist
- Conclusion
Flag Types and Decision Tree
Not all flags are created equal. Each type has different lifecycle, evaluation frequency, and business meaning.
// Flag evaluation context
interface EvaluationContext {
userId?: string;
organizationId?: string;
environment: 'development' | 'staging' | 'production';
userAttributes?: Record<string, string | number | boolean>;
}
// Flag types with distinct characteristics
enum FlagType {
// New feature being rolled out gradually
RELEASE = 'release',
// A/B test comparing variations
EXPERIMENT = 'experiment',
// Infrastructure/performance flag
OPS = 'ops',
// Permission/entitlement flag
PERMISSION = 'permission',
// Emergency kill switch
KILL_SWITCH = 'kill_switch',
}
interface FeatureFlag {
id: string;
key: string; // Code reference: 'new_dashboard_ui'
type: FlagType;
description: string;
defaultValue: boolean | string;
variations?: Record<string, unknown>;
rules: FlagRule[];
targets: FlagTarget[];
percentage?: number; // 0-100
createdAt: Date;
createdBy: string;
deprecatedAt?: Date; // When flag is flagged for removal
expiresAt?: Date; // When flag auto-disables
}
interface FlagRule {
id: string;
name: string;
conditions: Condition[]; // AND'd together
variations: string[]; // Which variations can match
priority: number; // Higher = evaluated first
}
interface Condition {
attribute: string; // 'userId', 'plan', 'region'
operator: 'equals' | 'contains' | 'gt' | 'lt' | 'in';
value: unknown;
}
interface FlagTarget {
userId?: string;
organizationId?: string;
variation: string | boolean;
}
class FeatureFlagService {
constructor(
private flagStore: Map<string, FeatureFlag>,
private cache: Cache,
private analytics: AnalyticsClient
) {}
async evaluate(
flagKey: string,
context: EvaluationContext
): Promise<boolean | string> {
// Check cache first (5 minute TTL)
const cacheKey = `flag:${flagKey}:${context.userId || 'anon'}`;
const cached = await this.cache.get(cacheKey);
if (cached !== null) {
this.analytics.track('flag_evaluated', {
flagKey,
userId: context.userId,
cached: true,
});
return cached;
}
const flag = this.flagStore.get(flagKey);
if (!flag) {
throw new Error(`Flag not found: ${flagKey}`);
}
// Flags auto-disable when expired
if (flag.expiresAt && flag.expiresAt < new Date()) {
return flag.defaultValue;
}
let variation = flag.defaultValue;
// Evaluate in priority order
const rules = flag.rules.sort((a, b) => b.priority - a.priority);
for (const rule of rules) {
if (this.ruleMatches(rule, context)) {
// Deterministically select variation for this user
variation = this.selectVariation(
rule.variations,
context.userId || context.organizationId || 'anon'
);
break;
}
}
// Check explicit targets (highest priority)
const target = flag.targets.find(
t => (t.userId && t.userId === context.userId) ||
(t.organizationId && t.organizationId === context.organizationId)
);
if (target) {
variation = target.variation;
}
// Percentage-based rollout (if no explicit target)
if (flag.percentage && !target) {
const hash = this.hashContext(flagKey, context);
variation = hash < flag.percentage ? true : false;
}
// Cache result
await this.cache.set(cacheKey, variation, 300);
// Record evaluation
this.analytics.track('flag_evaluated', {
flagKey,
userId: context.userId,
variation,
});
return variation;
}
private ruleMatches(rule: FlagRule, context: EvaluationContext): boolean {
return rule.conditions.every(condition => {
const value = this.getContextValue(context, condition.attribute);
switch (condition.operator) {
case 'equals':
return value === condition.value;
case 'contains':
return String(value).includes(String(condition.value));
case 'gt':
return Number(value) > Number(condition.value);
case 'lt':
return Number(value) < Number(condition.value);
case 'in':
return Array.isArray(condition.value) &&
condition.value.includes(value);
default:
return false;
}
});
}
private selectVariation(
variations: string[],
seed: string
): string {
// Deterministic: same user always gets same variation
const hash = this.simpleHash(seed);
const index = hash % variations.length;
return variations[index];
}
private hashContext(flagKey: string, context: EvaluationContext): number {
// Percentage calculation: hash(flag + user) % 100
const seed = `${flagKey}:${context.userId || context.organizationId}`;
return Math.abs(this.simpleHash(seed)) % 100;
}
private simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash = hash & hash;
}
return hash;
}
private getContextValue(context: EvaluationContext, attribute: string): any {
if (attribute === 'userId') return context.userId;
if (attribute === 'organizationId') return context.organizationId;
if (attribute === 'environment') return context.environment;
return context.userAttributes?.[attribute];
}
}
LaunchDarkly vs OpenFeature vs Custom
Compare production flag services and standards.
// LaunchDarkly integration (SaaS, battle-tested)
import LaunchDarkly from 'launchdarkly-node-server-sdk';
const client = LaunchDarkly.init('sdk-key');
const user = { key: 'user-123', email: 'user@example.com' };
const flagValue = client.variation('new-dashboard', user, false);
// OpenFeature: vendor-agnostic standard
import { OpenFeature } from '@openfeature/js-sdk';
import { LaunchDarlyProvider } from '@openfeature/launchdarkly-provider';
OpenFeature.setProvider(new LaunchDarlyProvider('sdk-key'));
const client = OpenFeature.getClient();
const context = { userId: 'user-123', orgId: 'org-456' };
const { value } = await client.getBooleanDetails('new-dashboard', false, context);
// Custom implementation (when you own the flag platform)
class SelfHostedFlagService {
private flags: Map<string, FeatureFlag>;
private redis: RedisClient;
private configRepo: GitHubRepository;
constructor() {
this.flags = new Map();
this.loadFlagsFromGit();
}
private async loadFlagsFromGit() {
// Flags defined in git (flags/production.yml)
const content = await this.configRepo.getFileContent('flags/production.yml');
const config = yaml.parse(content);
for (const [key, flag] of Object.entries(config.flags)) {
this.flags.set(key, {
key,
type: flag.type,
defaultValue: flag.default,
percentage: flag.percentage,
rules: flag.rules || [],
targets: flag.targets || [],
createdAt: new Date(flag.created_at),
createdBy: flag.created_by,
});
}
}
async evaluate(key: string, context: EvaluationContext): Promise<boolean> {
// Check cache first
const cached = await this.redis.get(`flag:${key}:${context.userId}`);
if (cached !== null) return JSON.parse(cached);
const flag = this.flags.get(key);
if (!flag) return false;
const result = this.evaluateFlag(flag, context);
await this.redis.setex(`flag:${key}:${context.userId}`, 300, JSON.stringify(result));
return result;
}
private evaluateFlag(flag: FeatureFlag, context: EvaluationContext): boolean {
// Implementation similar to earlier
return true;
}
}
// Comparison
const comparison = {
'LaunchDarkly': {
hostingModel: 'SaaS',
cost: 'Per user',
setup: 'Minutes',
support: 'Enterprise',
analytics: 'Built-in',
permissions: 'RBAC',
},
'OpenFeature': {
hostingModel: 'Standard',
cost: 'Depends on provider',
setup: 'Via provider',
support: 'CNCF',
analytics: 'Provider-specific',
permissions: 'Provider-specific',
},
'Custom/Self-Hosted': {
hostingModel: 'Self-hosted',
cost: 'Infrastructure',
setup: 'Weeks',
support: 'Internal',
analytics: 'Must build',
permissions: 'DIY',
},
};
Percentage Rollouts
Gradually expose features to percentages of users.
class PercentageRollout {
// Deterministic: same user always gets same bucket
// Hash-based: user 123 is always in 45% if rollout is 50%
static calculatePercentile(userId: string, flagKey: string): number {
const combined = `${flagKey}:${userId}`;
let hash = 0;
for (let i = 0; i < combined.length; i++) {
hash = ((hash << 5) - hash) + combined.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash) % 100;
}
static isUserIncluded(
userId: string,
flagKey: string,
percentage: number
): boolean {
const percentile = this.calculatePercentile(userId, flagKey);
return percentile < percentage;
}
// Canary: gradually increase percentage
static getCanaryPercentage(
startDate: Date,
currentDate: Date,
canaryDurationDays: number,
targetPercentage: number
): number {
const elapsedDays = (currentDate.getTime() - startDate.getTime()) /
(1000 * 60 * 60 * 24);
if (elapsedDays >= canaryDurationDays) {
return targetPercentage;
}
// Linear increase
return Math.floor((elapsedDays / canaryDurationDays) * targetPercentage);
}
}
// Rollout strategy
interface RolloutStrategy {
key: string;
startDate: Date;
targetPercentage: number;
canaryDurationDays?: number;
rolloutSchedule?: {
date: Date;
percentage: number;
}[];
}
class RolloutManager {
private strategies: Map<string, RolloutStrategy> = new Map();
createCanaryRollout(
flagKey: string,
startDate: Date,
targetPercentage: number,
canaryDurationDays: number = 7
): void {
this.strategies.set(flagKey, {
key: flagKey,
startDate,
targetPercentage,
canaryDurationDays,
});
}
createScheduledRollout(
flagKey: string,
schedule: { date: Date; percentage: number }[]
): void {
this.strategies.set(flagKey, {
key: flagKey,
startDate: schedule[0].date,
targetPercentage: schedule[schedule.length - 1].percentage,
rolloutSchedule: schedule,
});
}
getPercentageForFlag(flagKey: string, now: Date = new Date()): number {
const strategy = this.strategies.get(flagKey);
if (!strategy) return 0;
if (strategy.rolloutSchedule) {
// Find current percentage based on schedule
for (let i = strategy.rolloutSchedule.length - 1; i >= 0; i--) {
if (now >= strategy.rolloutSchedule[i].date) {
return strategy.rolloutSchedule[i].percentage;
}
}
return strategy.rolloutSchedule[0].percentage;
}
if (strategy.canaryDurationDays) {
return PercentageRollout.getCanaryPercentage(
strategy.startDate,
now,
strategy.canaryDurationDays,
strategy.targetPercentage
);
}
return strategy.targetPercentage;
}
isUserInRollout(
userId: string,
flagKey: string,
now: Date = new Date()
): boolean {
const percentage = this.getPercentageForFlag(flagKey, now);
return PercentageRollout.isUserIncluded(userId, flagKey, percentage);
}
}
// Test determinism
describe('Percentage Rollouts', () => {
it('same user always gets same bucket', () => {
const userId = 'user-123';
const flagKey = 'new_feature';
const percentile1 = PercentageRollout.calculatePercentile(userId, flagKey);
const percentile2 = PercentageRollout.calculatePercentile(userId, flagKey);
expect(percentile1).toBe(percentile2);
});
it('consistent distribution across users', () => {
const flagKey = 'test_flag';
let count = 0;
for (let i = 0; i < 10000; i++) {
if (PercentageRollout.isUserIncluded(`user-${i}`, flagKey, 50)) {
count++;
}
}
// Should be approximately 50% (allow 1% variance)
expect(count).toBeGreaterThan(4900);
expect(count).toBeLessThan(5100);
});
it('canary rollout increases over time', () => {
const startDate = new Date('2026-03-01');
const flagKey = 'new_dashboard';
// Day 1: ~0%
let percentage = RolloutManager.getCanaryPercentage(
startDate,
new Date('2026-03-02'),
7,
100
);
expect(percentage).toBeLessThan(20);
// Day 4: ~50%
percentage = RolloutManager.getCanaryPercentage(
startDate,
new Date('2026-03-05'),
7,
100
);
expect(percentage).toBeGreaterThan(40);
expect(percentage).toBeLessThan(60);
// Day 8: 100%
percentage = RolloutManager.getCanaryPercentage(
startDate,
new Date('2026-03-09'),
7,
100
);
expect(percentage).toBe(100);
});
});
User Targeting Rules
Target specific users, organizations, or segments.
interface TargetingRule {
id: string;
name: string;
enabled: boolean;
conditions: TargetingCondition[];
variation: string | boolean;
priority: number; // Higher = evaluated first
}
interface TargetingCondition {
attribute: string; // 'userId', 'email', 'plan', 'region'
operator: 'equals' | 'startsWith' | 'endsWith' | 'contains' | 'in' | 'regex';
value: string | string[];
caseSensitive?: boolean;
}
class TargetingEngine {
evaluateRules(
context: EvaluationContext,
rules: TargetingRule[]
): string | boolean | null {
// Sort by priority (higher first)
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
for (const rule of sorted) {
if (!rule.enabled) continue;
if (this.matchesAllConditions(context, rule.conditions)) {
return rule.variation;
}
}
return null;
}
private matchesAllConditions(
context: EvaluationContext,
conditions: TargetingCondition[]
): boolean {
return conditions.every(condition =>
this.matchesCondition(context, condition)
);
}
private matchesCondition(
context: EvaluationContext,
condition: TargetingCondition
): boolean {
const value = this.getAttribute(context, condition.attribute);
if (value === undefined) return false;
const strValue = String(value);
const strCondition = condition.value;
switch (condition.operator) {
case 'equals':
return condition.caseSensitive
? strValue === strCondition
: strValue.toLowerCase() === String(strCondition).toLowerCase();
case 'startsWith':
return strValue.startsWith(String(strCondition));
case 'endsWith':
return strValue.endsWith(String(strCondition));
case 'contains':
return strValue.includes(String(strCondition));
case 'in':
return Array.isArray(condition.value) &&
condition.value.includes(strValue);
case 'regex':
return new RegExp(String(strCondition)).test(strValue);
default:
return false;
}
}
private getAttribute(context: EvaluationContext, attribute: string): any {
// Standard attributes
if (attribute === 'userId') return context.userId;
if (attribute === 'organizationId') return context.organizationId;
if (attribute === 'environment') return context.environment;
// Custom attributes from userAttributes
return context.userAttributes?.[attribute];
}
}
// Example targeting rules
const rules: TargetingRule[] = [
{
id: 'rule-1',
name: 'Beta testers',
enabled: true,
conditions: [
{
attribute: 'email',
operator: 'endsWith',
value: '@betatesters.com',
},
],
variation: 'variation_b',
priority: 10,
},
{
id: 'rule-2',
name: 'Enterprise plan',
enabled: true,
conditions: [
{
attribute: 'plan',
operator: 'equals',
value: 'enterprise',
caseSensitive: false,
},
],
variation: true,
priority: 5,
},
{
id: 'rule-3',
name: 'Specific organizations',
enabled: true,
conditions: [
{
attribute: 'organizationId',
operator: 'in',
value: [
'org-123',
'org-456',
'org-789',
],
},
],
variation: true,
priority: 20,
},
];
Stale Flag Detection and Cleanup
Identify and remove flags that are no longer needed.
interface FlagMetrics {
flagKey: string;
evaluations: number;
lastEvaluatedAt: Date;
usersAffected: number;
variations: Record<string, number>; // Count per variation
createdAt: Date;
deprecatedAt?: Date;
}
class StaleFlagDetector {
private metricsStore: MetricsDatabase;
private threshold = {
noEvaluationsForDays: 7,
onlyDefaultVariation: true,
evaluationThreshold: 100, // Minimum evaluations
};
async detectStaleFlags(): Promise<string[]> {
const allFlags = await this.getFlags();
const staleFlags: string[] = [];
for (const flag of allFlags) {
const metrics = await this.metricsStore.getMetrics(flag.key);
// Not evaluated in last 7 days
const daysSinceEval = this.daysSince(metrics.lastEvaluatedAt);
if (daysSinceEval > this.threshold.noEvaluationsForDays) {
staleFlags.push(flag.key);
continue;
}
// Very low evaluation count (typo in flag key?)
if (metrics.evaluations < this.threshold.evaluationThreshold) {
staleFlags.push(flag.key);
continue;
}
// Only ever evaluates to default variation
if (
this.threshold.onlyDefaultVariation &&
Object.values(metrics.variations).every(
(count, _, arr) => count === 0 || count === arr[0]
)
) {
staleFlags.push(flag.key);
}
}
return staleFlags;
}
async deprecateFlag(
flagKey: string,
removalDate: Date = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
): Promise<void> {
const flag = await this.getFlag(flagKey);
flag.deprecatedAt = new Date();
flag.expiresAt = removalDate;
await this.updateFlag(flag);
// Send notifications
await this.notifyTeam(
`Flag ${flagKey} deprecated. Will be removed on ${removalDate.toISOString()}`
);
// Create PR removing from code
await this.createCleanupPR(flagKey, removalDate);
}
private async createCleanupPR(
flagKey: string,
removalDate: Date
): Promise<void> {
// Find usages
const usages = await this.searchCodebase(flagKey);
if (usages.length === 0) {
console.log(`No code usages found for ${flagKey}`);
return;
}
// Create branch
const branchName = `cleanup/remove-flag-${flagKey}`;
await this.git.createBranch(branchName);
// Remove flag from code
for (const usage of usages) {
await this.removeUsage(usage);
}
// Create PR with deadline
await this.git.createPullRequest({
title: `Remove feature flag: ${flagKey}`,
body: `This flag was deprecated on ${new Date().toISOString()} and should be removed by ${removalDate.toISOString()}`,
labels: ['cleanup', 'feature-flags'],
});
}
private daysSince(date: Date): number {
return (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24);
}
}
// Automated cleanup job
async function cleanupStaleFlags(): Promise<void> {
const detector = new StaleFlagDetector(metricsStore);
const staleFlags = await detector.detectStaleFlags();
console.log(`Found ${staleFlags.length} stale flags`);
for (const flagKey of staleFlags) {
await detector.deprecateFlag(flagKey);
}
}
Trunk-Based Development with Flags
Use flags to enable continuous deployment without feature branches.
// No feature branches - just flags
// Branch from main, make changes, merge to main same day
// Gate new code behind flag
// src/features/new-dashboard.ts
const NewDashboardFeature = ({ user }: { user: User }) => {
const [isEnabled] = useFeatureFlag('new_dashboard_ui', { userId: user.id });
return isEnabled ? <NewDashboard /> : <LegacyDashboard />;
};
// Merge directly to main - flag off by default
// Gradually increase rollout percentage over days
// If issues detected, instant rollback by toggling flag
// Rollout schedule in git
// flags/production.yml
flags:
new_dashboard_ui:
type: release
default: false
rollout:
schedule:
- date: 2026-03-20
percentage: 5 # canary
- date: 2026-03-21
percentage: 25
- date: 2026-03-22
percentage: 50
- date: 2026-03-23
percentage: 100
canary_duration_days: 3
// Monitoring
// Alert if error rate increases when flag is enabled
// Alert if performance metrics degrade
// Auto-rollback if threshold breached
class RolloutMonitor {
async monitorFlagRollout(flagKey: string): Promise<void> {
const baseline = await this.getMetricsBeforeFlagEnable(flagKey);
// Enable flag gradually
await this.graduallyEnableFlag(flagKey);
// Monitor metrics every minute
const interval = setInterval(async () => {
const current = await this.getCurrentMetrics(flagKey);
const errorRateIncrease =
(current.errorRate - baseline.errorRate) / baseline.errorRate * 100;
if (errorRateIncrease > 50) {
console.error(
`Error rate increased ${errorRateIncrease}% for ${flagKey}`
);
await this.disableFlag(flagKey);
await this.alertTeam(`Flag ${flagKey} auto-disabled due to errors`);
clearInterval(interval);
return;
}
const p99Regression =
(current.p99Latency - baseline.p99Latency) / baseline.p99Latency * 100;
if (p99Regression > 30) {
console.error(`P99 latency increased ${p99Regression}% for ${flagKey}`);
await this.disableFlag(flagKey);
await this.alertTeam(`Flag ${flagKey} auto-disabled due to latency`);
clearInterval(interval);
}
}, 60000);
}
private async graduallyEnableFlag(flagKey: string): Promise<void> {
// Increase percentage slowly
for (let percentage of [5, 10, 25, 50, 100]) {
await this.setFlagPercentage(flagKey, percentage);
// Wait for stabilization
await new Promise(resolve => setTimeout(resolve, 300000)); // 5 minutes
}
}
}
Feature Flags Checklist
- All flags have clear type (release, experiment, ops, permission, kill-switch)
- Flags have expiration dates (auto-disable if not removed)
- Default values are safe (usually false/disabled)
- Targeting rules evaluated in priority order
- Percentage rollouts use deterministic hashing (same user always same bucket)
- Metrics tracked: evaluations, variations, affected users
- Stale flags detected and deprecated automatically
- Code cleanup PRs created for deprecated flags
- Rollout monitored for error rate and performance regression
- Flag failures logged without breaking application
- Kill switch flags bypass all logic to stop feature instantly
Conclusion
Feature flags enable trunk-based development, safe rollouts, and instant rollbacks. Start with simple percentage-based rollouts, add user targeting, monitor metrics, and automate detection of stale flags. Use LaunchDarkly for managed SaaS, OpenFeature for vendor neutrality, or build custom when you control the entire platform.