Deployment Strategies
Feature Flags
Read aloud is unavailable in this browser.
Decoupling Deployment from Release
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.
Feature Flag Fundamentals
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.
- •Release toggles — Hide incomplete features in production until ready. Enable trunk-based development without feature branches.
- •Ops toggles — Runtime controls for operational concerns. Disable expensive features during incidents, toggle between implementations.
- •Experiment toggles — A/B testing and multivariate experiments. Compare user behavior across feature variations.
- •Permission toggles — Feature gating based on user tier, subscription level, or entitlements. Premium features, beta access.
- •Kill switches — Instantly disable problematic features without deployment. Emergency response mechanism.
- •Configuration toggles — Environment-specific behavior without code changes. Different settings per region, deployment tier.
| 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.
Implementation Patterns
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.
Targeting Rules and Segmentation
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.
Feature Flag System Architecture
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).
- •Flag Management UI — Dashboard for creating, editing, and monitoring flags. Audience targeting, audit history, metrics visualization.
- •Flag Storage — Persistent storage for flag definitions and rules. Database or configuration store with versioning.
- •Flag API — REST/GraphQL API for flag management. Used by UI and CI/CD integrations.
- •Edge Cache — CDN or edge nodes caching flag definitions. Reduces latency for SDKs fetching flags.
- •Client SDKs — Language-specific libraries for evaluating flags. Local caching, streaming updates, event tracking.
- •Streaming Service — Real-time push of flag updates to connected SDKs. Server-sent events or WebSocket.
- •Analytics Pipeline — Collection and analysis of flag evaluation events. Exposure tracking, experiment analysis.
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.
Flag Lifecycle and Technical Debt
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:
- •Creation — Flag created with clear purpose, owner, and expected removal date. Documented in ticket/issue.
- •Development — Code written behind the flag. Flag defaults to false (disabled in production).
- •Testing — Flag enabled in development/staging. QA validates both flag states.
- •Gradual rollout — Flag enabled for increasing percentage in production. Monitoring for issues.
- •Full rollout — Flag enabled for 100% of users. Feature is live.
- •Monitoring period — Wait period to confirm stability (typically 1-2 weeks).
- •Flag removal — Remove flag checks from code, delete flag definition. Technical debt avoided.
| 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.
Testing Strategies for Feature Flags
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.
- •Test both paths explicitly — Unit tests should verify behavior with flag on and off. Don't just test the 'new' path.
- •Flag-aware test configuration — Test framework can override flags for specific test cases. Each test knows its flag state.
- •Integration test matrix — Key integration tests run with different flag combinations. Cover critical paths.
- •Production verification — After rollout, run verification tests against production with flag enabled.
- •Fallback testing — Verify that default values work correctly if flag service is unavailable.
- •Targeting rule testing — Verify that targeting rules evaluate correctly for different user profiles.
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.
Integrating Flags with Deployment Strategies
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.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.