Loading content...
Traditional software development conflates two distinct concepts: deployment (pushing code to production) and release (making features available to users). This coupling creates risk—every deployment becomes a high-stakes event where new features are immediately exposed to all users.
Feature flags fundamentally decouple these concepts. Code can be deployed but remain invisible to users until explicitly enabled. This separation enables powerful patterns: gradual rollouts, instant kill switches, A/B testing, and environment-specific configurations—all without redeployment.
By the end of this page, you will understand how to design and implement feature flag systems, define targeting rules for granular control, manage flag lifecycle to avoid technical debt, and integrate flags with deployment strategies. You'll be equipped to use feature flags safely and effectively in production.
A feature flag (also called feature toggle, feature switch, or feature gate) is a runtime mechanism that controls whether a piece of functionality is available. At its simplest, it's a conditional statement that checks a flag value before executing code.
The core concept:
if (featureFlags.isEnabled('new-checkout-flow')) {
showNewCheckoutFlow();
} else {
showLegacyCheckoutFlow();
}
This simple pattern unlocks powerful capabilities that transform how software is built and released.
| Category | Typical Duration | Example | Cleanup Priority |
|---|---|---|---|
| Release toggle | Days to weeks | New UI component | High—remove after rollout |
| Experiment toggle | Weeks to months | A/B test checkout flow | Medium—remove after analysis |
| Ops toggle | Indefinite | Circuit breaker for external API | Low—intentionally permanent |
| Permission toggle | Indefinite | Premium tier feature access | None—permanent architecture |
| Kill switch | Indefinite | Emergency feature disable | None—permanent safety mechanism |
Feature flags are dynamic and often per-user or per-request. Configuration is typically static and environment-wide. Use flags for feature control and behavioral variation; use configuration for environment-specific settings like database URLs or API endpoints.
Feature flag implementations range from simple in-memory dictionaries to sophisticated distributed systems with real-time updates, targeting rules, and audit logging. The right choice depends on your scale and requirements.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
/** * Feature Flag SDK Implementation * * This implementation demonstrates: * - Flag evaluation with user context * - Targeting rules (percentage, user-based, attribute-based) * - Default values and fallbacks * - Real-time updates via streaming */ interface UserContext { userId: string; email?: string; country?: string; subscriptionTier?: 'free' | 'pro' | 'enterprise'; createdAt?: Date; customAttributes?: Record<string, string | number | boolean>;} interface FlagRule { type: 'percentage' | 'userList' | 'attribute' | 'everyone'; percentage?: number; userIds?: string[]; attribute?: string; operator?: 'equals' | 'contains' | 'greaterThan' | 'lessThan' | 'in'; value?: unknown; variation: boolean | string | number;} interface Flag { key: string; defaultValue: boolean | string | number; rules: FlagRule[]; enabled: boolean; // Master kill switch updatedAt: Date;} class FeatureFlagClient { private flags: Map<string, Flag> = new Map(); private eventStream: EventSource | null = null; constructor(private apiKey: string, private baseUrl: string) { this.initialize(); } private async initialize(): Promise<void> { // Initial fetch of all flags await this.fetchFlags(); // Set up streaming updates for real-time changes this.eventStream = new EventSource( `${this.baseUrl}/stream?apiKey=${this.apiKey}` ); this.eventStream.onmessage = (event) => { const update = JSON.parse(event.data); this.flags.set(update.key, update.flag); console.log(`Flag updated: ${update.key}`); }; } private async fetchFlags(): Promise<void> { const response = await fetch(`${this.baseUrl}/flags`, { headers: { 'Authorization': `Bearer ${this.apiKey}` } }); const flags = await response.json(); for (const flag of flags) { this.flags.set(flag.key, flag); } } /** * Evaluate a feature flag for a given user context. * Returns the flag value after evaluating all rules. */ isEnabled(flagKey: string, user: UserContext): boolean { const value = this.evaluate(flagKey, user); return value === true; } getValue<T>(flagKey: string, user: UserContext, defaultValue: T): T { const value = this.evaluate(flagKey, user); return (value as T) ?? defaultValue; } private evaluate(flagKey: string, user: UserContext): unknown { const flag = this.flags.get(flagKey); if (!flag) { console.warn(`Unknown flag: ${flagKey}`); return undefined; } // Master kill switch if (!flag.enabled) { return flag.defaultValue; } // Evaluate rules in order (first match wins) for (const rule of flag.rules) { if (this.evaluateRule(rule, user)) { return rule.variation; } } return flag.defaultValue; } private evaluateRule(rule: FlagRule, user: UserContext): boolean { switch (rule.type) { case 'everyone': return true; case 'userList': return rule.userIds?.includes(user.userId) ?? false; case 'percentage': // Consistent hashing: same user always gets same result const hash = this.hashUserId(user.userId); return hash < (rule.percentage ?? 0); case 'attribute': return this.evaluateAttribute(rule, user); default: return false; } } private evaluateAttribute(rule: FlagRule, user: UserContext): boolean { const attrValue = rule.attribute ? (user as any)[rule.attribute] ?? user.customAttributes?.[rule.attribute] : undefined; if (attrValue === undefined) return false; switch (rule.operator) { case 'equals': return attrValue === rule.value; case 'contains': return String(attrValue).includes(String(rule.value)); case 'greaterThan': return Number(attrValue) > Number(rule.value); case 'lessThan': return Number(attrValue) < Number(rule.value); case 'in': return (rule.value as unknown[]).includes(attrValue); default: return false; } } private hashUserId(userId: string): number { // Simple consistent hash producing 0-99 for percentage targeting let hash = 0; for (let i = 0; i < userId.length; i++) { hash = ((hash << 5) - hash) + userId.charCodeAt(i); hash = hash & hash; } return Math.abs(hash) % 100; } /** * Track when a flag is evaluated (for analytics) */ track(flagKey: string, user: UserContext, value: unknown): void { // Fire-and-forget analytics event fetch(`${this.baseUrl}/track`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ flagKey, userId: user.userId, value, timestamp: new Date().toISOString() }) }).catch(err => console.error('Flag tracking failed:', err)); }} // Usage exampleconst client = new FeatureFlagClient( process.env.FLAG_API_KEY!, 'https://flags.example.com'); function renderCheckout(user: UserContext): JSX.Element { if (client.isEnabled('new-checkout-flow', user)) { client.track('new-checkout-flow', user, true); return <NewCheckoutFlow />; } client.track('new-checkout-flow', user, false); return <LegacyCheckoutFlow />;}Flag evaluation happens on every request. Cache flag definitions locally and use streaming updates rather than fetching on each evaluation. Never make network calls in the hot path of flag evaluation.
The power of feature flags comes from targeting rules—conditions that determine which users see which variation. Sophisticated targeting enables gradual rollouts, beta programs, and experimentation without code changes.
| Rule Type | Use Case | Example |
|---|---|---|
| Percentage rollout | Gradual release to percentage of users | 10% of users see new feature |
| User targeting | Specific users for testing | Users with IDs in ["alice", "bob"] |
| Email domain | Internal testing | Users with @company.com email |
| Geographic | Regional rollout | Users in ["US", "CA"] |
| Subscription tier | Premium features | Users with tier = "enterprise" |
| User attribute | Cohort targeting | Users where signup_date > 2024-01-01 |
| Random (bucketed) | A/B experiments | Hash(userId) determines variant |
Rule precedence and default values:
Rules are evaluated in order. The first matching rule determines the flag value. If no rules match, the default value is returned.
Flag: new-payment-provider
Default: false (disabled)
Rules (evaluated in order):
1. IF user.email ends with @ourcompany.com → true (internal testing)
2. IF user.id IN [beta-tester-list] → true (beta program)
3. IF user.country IN ["US", "CA"] AND percentage(5%) → true (5% rollout in NA)
4. IF user.subscriptionTier = "enterprise" AND percentage(25%) → true (25% for enterprise)
Otherwise: false (default)
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
{ "flagKey": "new-checkout-flow", "enabled": true, "defaultValue": false, "rules": [ { "id": "internal-testing", "description": "Enable for all internal users", "clauses": [ { "attribute": "email", "operator": "endsWith", "values": ["@ourcompany.com"] } ], "variation": true, "priority": 1 }, { "id": "beta-program", "description": "Enable for opted-in beta testers", "clauses": [ { "attribute": "betaOptIn", "operator": "equals", "values": [true] } ], "variation": true, "priority": 2 }, { "id": "gradual-rollout", "description": "Gradual rollout to 10% of remaining users", "clauses": [ { "attribute": "userId", "operator": "percentageHash", "values": [10] } ], "variation": true, "priority": 3 } ], "audit": { "createdAt": "2024-01-15T10:00:00Z", "createdBy": "alice@ourcompany.com", "lastModifiedAt": "2024-01-20T14:30:00Z", "lastModifiedBy": "bob@ourcompany.com" }}Use consistent hashing (hash of user ID) for percentage rollouts, not random selection. This ensures the same user always gets the same flag value across requests and sessions. It also ensures gradual rollout is additive—users in 5% are also in 10%, 25%, etc.
Production feature flag systems require careful architectural consideration. The system must be highly available (flags failing closed could break production), low latency (evaluated on every request), and operationally excellent (audit logs, safe rollouts).
Resilience and fallback behavior:
Feature flag systems must degrade gracefully. If the flag service is unavailable, applications should fall back to safe defaults.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
package flags import ( "context" "sync" "time") // ResilientFlagClient provides fault-tolerant flag evaluationtype ResilientFlagClient struct { primary FlagProvider fallback FlagProvider cache sync.Map lastFetch time.Time mu sync.RWMutex} // FlagProvider interface for different flag sourcestype FlagProvider interface { GetFlag(ctx context.Context, key string) (*Flag, error) GetAllFlags(ctx context.Context) (map[string]*Flag, error)} // Evaluate a flag with multiple fallback layersfunc (c *ResilientFlagClient) IsEnabled(ctx context.Context, key string, user User, defaultValue bool) bool { value, err := c.evaluate(ctx, key, user) if err != nil { // Log error but don't fail log.Printf("Flag evaluation failed for %s: %v, using default: %v", key, err, defaultValue) return defaultValue } return value} func (c *ResilientFlagClient) evaluate(ctx context.Context, key string, user User) (bool, error) { // Layer 1: Try in-memory cache (refreshed periodically) if cached, ok := c.cache.Load(key); ok { flag := cached.(*Flag) return c.evaluateRules(flag, user), nil } // Layer 2: Try primary provider (feature flag service) ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond) defer cancel() flag, err := c.primary.GetFlag(ctx, key) if err == nil { c.cache.Store(key, flag) return c.evaluateRules(flag, user), nil } // Layer 3: Try fallback provider (local config file, environment variables) flag, err = c.fallback.GetFlag(ctx, key) if err == nil { return c.evaluateRules(flag, user), nil } // Layer 4: Return error, caller will use default return false, err} // Background refresh of flag cachefunc (c *ResilientFlagClient) StartBackgroundRefresh(ctx context.Context, interval time.Duration) { go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: c.refreshCache(ctx) } } }()} func (c *ResilientFlagClient) refreshCache(ctx context.Context) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() flags, err := c.primary.GetAllFlags(ctx) if err != nil { log.Printf("Failed to refresh flag cache: %v", err) return } for key, flag := range flags { c.cache.Store(key, flag) } c.mu.Lock() c.lastFetch = time.Now() c.mu.Unlock() log.Printf("Refreshed %d flags", len(flags))}If flag evaluation blocks on a network call and the flag service is down, your entire application fails. Always use local caching with background refresh. The flag service being down should never cause production outages.
Feature flags, if not managed carefully, become technical debt. Stale flags clutter the codebase, confuse developers, and create maintenance burden. A disciplined lifecycle process is essential.
The flag lifecycle:
| Practice | Implementation | Benefit |
|---|---|---|
| Expiration dates | Set expected removal date at creation | Triggers cleanup reminders |
| Flag ownership | Assign owner responsible for lifecycle | Accountability for removal |
| Stale flag alerts | Alert when flag unchanged for 30+ days | Identifies forgotten flags |
| 100% rollout alerts | Notify when flag at 100% for 14+ days | Triggers cleanup process |
| Dead code detection | CI check for flags enabled 100% globally | Automated cleanup assistance |
| Flag inventory review | Monthly review of active flags | Regular debt assessment |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
/** * Automated flag cleanup system * * Detects stale flags and creates cleanup issues */ interface FlagMetadata { key: string; createdAt: Date; owner: string; expirationDate?: Date; currentPercentage: number; lastModified: Date; purpose: string;} async function auditFlags(): Promise<void> { const flags = await flagService.getAllFlags(); const issues: string[] = []; for (const flag of flags) { // Check for expired flags if (flag.expirationDate && flag.expirationDate < new Date()) { issues.push( `🚨 EXPIRED: "${flag.key}" expired on ${flag.expirationDate.toDateString()}. ` + `Owner: ${flag.owner}` ); } // Check for flags at 100% for too long const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); if (flag.currentPercentage === 100 && flag.lastModified < twoWeeksAgo) { issues.push( `⏰ STALE 100%: "${flag.key}" has been at 100% since ${flag.lastModified.toDateString()}. ` + `Consider removing. Owner: ${flag.owner}` ); } // Check for flags with no activity const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); if (flag.lastModified < thirtyDaysAgo) { issues.push( `💤 INACTIVE: "${flag.key}" hasn't been modified in 30+ days. ` + `Last touch: ${flag.lastModified.toDateString()}. Owner: ${flag.owner}` ); } } if (issues.length > 0) { // Create issue for flag cleanup await createCleanupIssue(issues); // Send Slack notification await notifyOwners(issues); } console.log(`Flag audit complete. Found ${issues.length} issues.`);} async function createCleanupIssue(issues: string[]): Promise<void> { const issueBody = `## Feature Flag Cleanup Required The following flags require attention: ${issues.map(i => `- ${i}`).join('')} ### Action Required 1. For expired flags: Remove flag and associated code branches2. For 100% flags: Remove the flag, keep the enabled code path3. For inactive flags: Confirm if still needed, update expiration date or remove See [Flag Cleanup Runbook](https://wiki/flag-cleanup) for detailed steps. `; await jiraClient.createIssue({ project: 'TECH-DEBT', type: 'Task', title: `[Automated] Feature Flag Cleanup - ${new Date().toISOString().split('T')[0]}`, body: issueBody, labels: ['feature-flags', 'tech-debt', 'automated'] });} // Run audit dailysetInterval(auditFlags, 24 * 60 * 60 * 1000);Companies with poor flag hygiene accumulate thousands of forgotten flags. Each adds cognitive load ("is this flag still used?"), code complexity (nested conditionals), and testing burden (must test both paths). Treat flag removal with the same urgency as flag creation.
Feature flags create testing complexity—you now have 2^n possible code paths for n flags. Effective testing strategies manage this complexity while ensuring both flag states work correctly.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
import { describe, it, expect, beforeEach } from 'vitest';import { FlagClient, MockFlagProvider } from './feature-flags';import { CheckoutService } from './checkout'; describe('CheckoutService', () => { let flagProvider: MockFlagProvider; let checkoutService: CheckoutService; beforeEach(() => { flagProvider = new MockFlagProvider(); checkoutService = new CheckoutService(new FlagClient(flagProvider)); }); describe('new-checkout-flow flag', () => { it('uses legacy flow when flag is disabled', async () => { // Arrange flagProvider.setFlag('new-checkout-flow', false); // Act const result = await checkoutService.processCheckout(mockCart, mockUser); // Assert expect(result.flow).toBe('legacy'); expect(result.legacyFieldsPopulated).toBe(true); }); it('uses new flow when flag is enabled', async () => { // Arrange flagProvider.setFlag('new-checkout-flow', true); // Act const result = await checkoutService.processCheckout(mockCart, mockUser); // Assert expect(result.flow).toBe('new'); expect(result.newFieldsPopulated).toBe(true); }); it('falls back to legacy when flag service unavailable', async () => { // Arrange flagProvider.simulateError(new Error('Service unavailable')); // Act const result = await checkoutService.processCheckout(mockCart, mockUser); // Assert - should degrade gracefully to default (false = legacy) expect(result.flow).toBe('legacy'); }); }); describe('targeting rules', () => { it('enables flag for internal users by email domain', async () => { // Arrange flagProvider.setFlagWithRules('new-feature', { defaultValue: false, rules: [ { type: 'email', operator: 'endsWith', value: '@company.com', variation: true } ] }); const internalUser = { ...mockUser, email: 'alice@company.com' }; const externalUser = { ...mockUser, email: 'bob@gmail.com' }; // Act & Assert expect(flagProvider.evaluate('new-feature', internalUser)).toBe(true); expect(flagProvider.evaluate('new-feature', externalUser)).toBe(false); }); it('percentage rollout is consistent for same user', async () => { // Arrange flagProvider.setFlagWithRules('gradual-rollout', { defaultValue: false, rules: [ { type: 'percentage', value: 50, variation: true } ] }); // Act - evaluate 100 times for same user const results = Array.from({ length: 100 }, () => flagProvider.evaluate('gradual-rollout', mockUser) ); // Assert - all results should be the same (consistent hashing) expect(new Set(results).size).toBe(1); }); });}); // Mock flag provider for testingclass MockFlagProvider { private flags = new Map<string, any>(); private error: Error | null = null; setFlag(key: string, value: boolean): void { this.flags.set(key, { key, defaultValue: value, rules: [] }); } setFlagWithRules(key: string, config: any): void { this.flags.set(key, { key, ...config }); } simulateError(error: Error): void { this.error = error; } getFlag(key: string): any { if (this.error) throw this.error; return this.flags.get(key); }}With 10 active flags, you have 1,024 possible combinations. Testing all is impractical. Focus on: (1) testing each flag in isolation, (2) testing known interdependent flags together, and (3) testing the most common production configurations.
Feature flags complement deployment strategies like canary and blue-green, providing additional layers of control. Using them together enables powerful release patterns.
| Pattern | How It Works | Benefit |
|---|---|---|
| Flag-gated canary | Deploy everywhere, flag controls exposure | Instant rollback via flag toggle |
| Dark launch | Deploy with flag off, enable after validation | Code in production, invisible to users |
| Gradual flag rollout | Increase flag percentage over time | Progressive exposure like canary, no infra |
| Regional flag control | Different flag percentage per region | Geographic rollout control |
| User cohort targeting | Enable for beta users regardless of deployment | Beta testing without canary infrastructure |
Example: Flag-gated release with instant rollback
Day 1: Deploy code with flag defaulting to false (0%)
- Code is in production but completely inactive
- Zero risk from deployment
Day 2: Enable flag for internal users (email @company.com)
- Internal dogfooding begins
- External users unaffected
Day 3: Enable flag for 5% of users
- Real user traffic to new code
- Monitor metrics
Day 4: Issue detected! Toggle flag to 0%
- Instant rollback, no deployment needed
- All users back to old behavior immediately
Day 5: Fix deployed with flag still at 0%
- Code fixed but not exposed
Day 6: Resume rollout, enable for 10%
- Continue gradual rollout
Day 7+: Increase to 25%, 50%, 100%
- Full rollout complete
Day 14: Remove flag, cleanup code branches
- Flag lifecycle complete
Even after full rollout, consider keeping the flag as a kill switch for 30 days. If delayed issues emerge, you can disable the feature instantly without deployment. Only remove the flag after confident stability.
While you can build a feature flag system in-house, mature platforms provide sophisticated capabilities out of the box. Evaluate based on your scale, compliance needs, and existing infrastructure.
| Platform | Strengths | Considerations | Best For |
|---|---|---|---|
| LaunchDarkly | Enterprise features, SDKs for all languages, streaming | High cost at scale | Enterprise, high-volume |
| Unleash | Open source, self-hosted, good feature set | Requires infrastructure management | Self-hosted, privacy focus |
| Flagsmith | Open source, hosted option, API-first | Smaller ecosystem | Startups, API-centric teams |
| Split.io | Experimentation focus, statistical analysis | Complexity for simple use cases | A/B testing heavy orgs |
| ConfigCat | Simple, affordable, good UX | Fewer advanced features | Small to medium teams |
| AWS AppConfig | AWS native, no additional vendor | AWS-only, fewer features | AWS-centric architectures |
| Custom-built | Full control, no vendor cost | Significant engineering investment | Unique requirements, large eng teams |
Open source solutions (Unleash, Flagsmith) provide flexibility and avoid vendor lock-in but require operational investment. Managed vendors (LaunchDarkly, Split) provide convenience and support but at higher cost. Evaluate based on your team's capacity and priorities.
Feature flags fundamentally change how software is released—decoupling deployment from release and enabling runtime control over feature visibility. Let's consolidate the key concepts:
What's next:
Even with the best deployment strategies and feature flags, things can go wrong. In the next page, we'll explore rollback strategies—the techniques and procedures for reverting changes when issues are detected, from instant rollback mechanisms to handling complex scenarios like database migrations.
You now understand feature flags comprehensively—from implementation patterns to lifecycle management, from testing strategies to vendor evaluation. This knowledge enables you to implement safe, controlled releases with runtime feature control.