Feature flags decouple deployment from release. This guide covers implementation patterns, best practices, and lifecycle management.
What Are Feature Flags?#
Feature flags are conditional statements that control feature visibility at runtime:
if (featureFlags.isEnabled('new-checkout')) {
return <NewCheckout />;
}
return <LegacyCheckout />;Types of Feature Flags#
Release Flags#
Control feature rollout:
1const flags = {
2 'new-dashboard': {
3 enabled: true,
4 rolloutPercentage: 25, // 25% of users
5 enabledForUsers: ['beta-testers'],
6 },
7};Experiment Flags#
A/B testing:
1const experiments = {
2 'checkout-button-color': {
3 variants: ['blue', 'green', 'orange'],
4 distribution: [0.33, 0.33, 0.34],
5 },
6};Operational Flags#
Kill switches:
const operationalFlags = {
'enable-caching': true,
'rate-limit-api': true,
'maintenance-mode': false,
};Implementation#
Basic Feature Flag Service#
1interface FeatureFlag {
2 key: string;
3 enabled: boolean;
4 rolloutPercentage?: number;
5 targetedUsers?: string[];
6 targetedGroups?: string[];
7}
8
9class FeatureFlagService {
10 private flags: Map<string, FeatureFlag>;
11
12 constructor(flags: FeatureFlag[]) {
13 this.flags = new Map(flags.map(f => [f.key, f]));
14 }
15
16 isEnabled(key: string, context?: UserContext): boolean {
17 const flag = this.flags.get(key);
18 if (!flag) return false;
19 if (!flag.enabled) return false;
20
21 // Check targeted users
22 if (flag.targetedUsers?.includes(context?.userId)) {
23 return true;
24 }
25
26 // Check targeted groups
27 if (flag.targetedGroups?.some(g => context?.groups.includes(g))) {
28 return true;
29 }
30
31 // Check rollout percentage
32 if (flag.rolloutPercentage !== undefined) {
33 const hash = this.hashUser(context?.userId || 'anonymous', key);
34 return hash < flag.rolloutPercentage;
35 }
36
37 return flag.enabled;
38 }
39
40 private hashUser(userId: string, flagKey: string): number {
41 const combined = `${userId}-${flagKey}`;
42 let hash = 0;
43 for (let i = 0; i < combined.length; i++) {
44 hash = ((hash << 5) - hash) + combined.charCodeAt(i);
45 hash = hash & hash;
46 }
47 return Math.abs(hash) % 100;
48 }
49}React Integration#
1import { createContext, useContext, ReactNode } from 'react';
2
3const FeatureFlagContext = createContext<FeatureFlagService | null>(null);
4
5export function FeatureFlagProvider({
6 children,
7 service,
8}: {
9 children: ReactNode;
10 service: FeatureFlagService;
11}) {
12 return (
13 <FeatureFlagContext.Provider value={service}>
14 {children}
15 </FeatureFlagContext.Provider>
16 );
17}
18
19export function useFeatureFlag(key: string): boolean {
20 const service = useContext(FeatureFlagContext);
21 const user = useCurrentUser();
22
23 if (!service) {
24 throw new Error('useFeatureFlag must be used within FeatureFlagProvider');
25 }
26
27 return service.isEnabled(key, {
28 userId: user?.id,
29 groups: user?.groups || [],
30 });
31}
32
33// Usage
34function Dashboard() {
35 const showNewWidget = useFeatureFlag('new-dashboard-widget');
36
37 return (
38 <div>
39 {showNewWidget && <NewWidget />}
40 <LegacyContent />
41 </div>
42 );
43}Server-Side Implementation#
1// middleware/featureFlags.ts
2import { NextRequest, NextResponse } from 'next/server';
3
4export async function middleware(request: NextRequest) {
5 const flags = await fetchFeatureFlags();
6 const userId = request.cookies.get('user-id')?.value;
7
8 // Add flags to request headers for downstream use
9 const response = NextResponse.next();
10 response.headers.set('x-feature-flags', JSON.stringify(
11 evaluateFlags(flags, { userId })
12 ));
13
14 return response;
15}Progressive Rollout Strategy#
Staged Rollout#
1const rolloutStages = [
2 { percentage: 1, duration: '1h', name: 'canary' },
3 { percentage: 10, duration: '4h', name: 'early-adopters' },
4 { percentage: 50, duration: '24h', name: 'half-rollout' },
5 { percentage: 100, duration: null, name: 'full-release' },
6];
7
8async function advanceRollout(flagKey: string) {
9 const flag = await getFlag(flagKey);
10 const currentStage = rolloutStages.findIndex(
11 s => s.percentage === flag.rolloutPercentage
12 );
13
14 if (currentStage < rolloutStages.length - 1) {
15 const nextStage = rolloutStages[currentStage + 1];
16
17 // Check error rates before advancing
18 const errorRate = await getErrorRate(flagKey);
19 if (errorRate > 0.01) { // 1% threshold
20 await rollback(flagKey);
21 return;
22 }
23
24 await updateFlag(flagKey, {
25 rolloutPercentage: nextStage.percentage,
26 });
27 }
28}Monitoring Integration#
1function trackFeatureFlagUsage(
2 flagKey: string,
3 enabled: boolean,
4 context: UserContext
5) {
6 analytics.track('feature_flag_evaluated', {
7 flag: flagKey,
8 enabled,
9 userId: context.userId,
10 timestamp: Date.now(),
11 });
12}
13
14// Track errors associated with flags
15function trackError(error: Error, activeFlags: string[]) {
16 errorTracking.captureException(error, {
17 tags: {
18 activeFeatureFlags: activeFlags.join(','),
19 },
20 });
21}Flag Lifecycle Management#
Expiration and Cleanup#
1interface FeatureFlagWithMetadata extends FeatureFlag {
2 createdAt: Date;
3 expiresAt?: Date;
4 owner: string;
5 status: 'active' | 'deprecated' | 'archived';
6}
7
8// Regular cleanup job
9async function cleanupExpiredFlags() {
10 const flags = await getAllFlags();
11 const now = new Date();
12
13 for (const flag of flags) {
14 if (flag.expiresAt && flag.expiresAt < now) {
15 // Notify owner before removal
16 await notifyOwner(flag.owner, `Flag "${flag.key}" has expired`);
17
18 // Check if flag is still in codebase
19 const inCodebase = await searchCodebase(flag.key);
20 if (!inCodebase) {
21 await archiveFlag(flag.key);
22 }
23 }
24 }
25}Documentation#
1// flags.config.ts
2export const featureFlags = {
3 'new-checkout': {
4 description: 'New streamlined checkout experience',
5 owner: 'checkout-team',
6 jiraTicket: 'SHOP-1234',
7 createdAt: '2024-01-15',
8 expectedRemoval: '2024-03-01',
9 dependencies: ['new-payment-provider'],
10 },
11} as const;Best Practices#
1. Keep Flags Short-Lived#
// Flag should be removed once feature is stable
// Bad: flags that exist for years
// Good: remove within 2-4 weeks of full rollout2. Use Descriptive Names#
1// Bad
2'flag-123'
3'test-feature'
4
5// Good
6'checkout-one-click-purchase'
7'dashboard-analytics-v2'3. Default to Off for New Features#
const defaultFlagState = {
enabled: false, // Safe default
rolloutPercentage: 0,
};4. Test Both States#
1describe('Checkout', () => {
2 it('works with new checkout enabled', () => {
3 setFeatureFlag('new-checkout', true);
4 // test new checkout
5 });
6
7 it('works with new checkout disabled', () => {
8 setFeatureFlag('new-checkout', false);
9 // test legacy checkout
10 });
11});Common Pitfalls#
- Flag explosion: Too many flags become unmanageable
- Stale flags: Old flags left in codebase
- Testing gaps: Not testing both flag states
- Performance impact: Evaluating too many flags per request
Conclusion#
Feature flags enable safer deployments and progressive rollouts. Implement a clear lifecycle from creation to removal, monitor flag impact, and maintain discipline around flag cleanup.