Feature flags decouple deployment from release. Ship code to production without exposing it to users, then gradually roll out when ready. Here's how to implement them effectively.
Why Feature Flags?#
Use cases:
- Gradual rollouts (1% → 10% → 50% → 100%)
- A/B testing
- Kill switches for problematic features
- Beta testing with specific users
- Trunk-based development
- Environment-specific features
Basic Implementation#
1// Simple feature flag service
2interface FeatureFlag {
3 name: string;
4 enabled: boolean;
5 percentage?: number;
6 allowedUsers?: string[];
7 allowedGroups?: string[];
8 metadata?: Record<string, any>;
9}
10
11class FeatureFlagService {
12 private flags: Map<string, FeatureFlag> = new Map();
13
14 constructor(private storage: FlagStorage) {}
15
16 async load(): Promise<void> {
17 const flags = await this.storage.getAll();
18 flags.forEach((flag) => this.flags.set(flag.name, flag));
19 }
20
21 isEnabled(
22 flagName: string,
23 context?: { userId?: string; groups?: string[] }
24 ): boolean {
25 const flag = this.flags.get(flagName);
26
27 if (!flag) return false;
28 if (!flag.enabled) return false;
29
30 // Check user allowlist
31 if (flag.allowedUsers?.length && context?.userId) {
32 if (flag.allowedUsers.includes(context.userId)) {
33 return true;
34 }
35 }
36
37 // Check group allowlist
38 if (flag.allowedGroups?.length && context?.groups?.length) {
39 if (flag.allowedGroups.some((g) => context.groups!.includes(g))) {
40 return true;
41 }
42 }
43
44 // Check percentage rollout
45 if (flag.percentage !== undefined && context?.userId) {
46 const hash = this.hashUserId(context.userId, flagName);
47 return hash < flag.percentage;
48 }
49
50 // No targeting, just check enabled
51 return flag.enabled && !flag.allowedUsers?.length && !flag.allowedGroups?.length;
52 }
53
54 private hashUserId(userId: string, flagName: string): number {
55 const str = `${userId}:${flagName}`;
56 let hash = 0;
57 for (let i = 0; i < str.length; i++) {
58 hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
59 }
60 return Math.abs(hash) % 100;
61 }
62}Database Schema#
1// schema.prisma
2model FeatureFlag {
3 id String @id @default(cuid())
4 name String @unique
5 description String?
6 enabled Boolean @default(false)
7 percentage Int? // 0-100 for rollout
8 allowedUsers String[] // User IDs
9 allowedGroups String[] // Group names
10 metadata Json?
11 createdAt DateTime @default(now())
12 updatedAt DateTime @updatedAt
13 createdBy String?
14}
15
16model FeatureFlagAudit {
17 id String @id @default(cuid())
18 flagName String
19 action String // created, updated, deleted
20 oldValue Json?
21 newValue Json?
22 userId String
23 timestamp DateTime @default(now())
24}React Integration#
1// Feature flag context
2const FeatureFlagContext = createContext<{
3 isEnabled: (flag: string) => boolean;
4 flags: Record<string, boolean>;
5}>({
6 isEnabled: () => false,
7 flags: {},
8});
9
10export function FeatureFlagProvider({
11 children,
12 userId,
13}: {
14 children: React.ReactNode;
15 userId?: string;
16}) {
17 const [flags, setFlags] = useState<Record<string, boolean>>({});
18
19 useEffect(() => {
20 fetch(`/api/feature-flags?userId=${userId}`)
21 .then((res) => res.json())
22 .then(setFlags);
23 }, [userId]);
24
25 const isEnabled = (flag: string) => flags[flag] ?? false;
26
27 return (
28 <FeatureFlagContext.Provider value={{ isEnabled, flags }}>
29 {children}
30 </FeatureFlagContext.Provider>
31 );
32}
33
34// Hook
35export function useFeatureFlag(flagName: string): boolean {
36 const { isEnabled } = useContext(FeatureFlagContext);
37 return isEnabled(flagName);
38}
39
40// Component
41export function Feature({
42 flag,
43 children,
44 fallback = null,
45}: {
46 flag: string;
47 children: React.ReactNode;
48 fallback?: React.ReactNode;
49}) {
50 const enabled = useFeatureFlag(flag);
51 return enabled ? <>{children}</> : <>{fallback}</>;
52}
53
54// Usage
55function Dashboard() {
56 return (
57 <div>
58 <Feature flag="new-dashboard">
59 <NewDashboard />
60 </Feature>
61
62 <Feature flag="analytics-v2" fallback={<AnalyticsV1 />}>
63 <AnalyticsV2 />
64 </Feature>
65 </div>
66 );
67}Gradual Rollout#
1// Rollout controller
2class RolloutController {
3 async updateRollout(
4 flagName: string,
5 percentage: number
6 ): Promise<void> {
7 if (percentage < 0 || percentage > 100) {
8 throw new Error('Percentage must be 0-100');
9 }
10
11 await prisma.featureFlag.update({
12 where: { name: flagName },
13 data: { percentage },
14 });
15
16 // Log audit
17 await this.logAudit(flagName, 'rollout_updated', { percentage });
18
19 // Invalidate cache
20 await this.invalidateCache(flagName);
21 }
22
23 async gradualRollout(
24 flagName: string,
25 steps: number[],
26 intervalMinutes: number
27 ): Promise<void> {
28 for (const percentage of steps) {
29 await this.updateRollout(flagName, percentage);
30 logger.info(`Rolled out ${flagName} to ${percentage}%`);
31
32 // Monitor for errors
33 await this.waitAndMonitor(intervalMinutes, flagName);
34
35 const errorRate = await this.getErrorRate(flagName);
36 if (errorRate > 0.01) {
37 // > 1% error rate
38 logger.error(`High error rate for ${flagName}, rolling back`);
39 await this.updateRollout(flagName, 0);
40 throw new Error('Rollout aborted due to high error rate');
41 }
42 }
43 }
44
45 private async waitAndMonitor(
46 minutes: number,
47 flagName: string
48 ): Promise<void> {
49 await new Promise((resolve) => setTimeout(resolve, minutes * 60 * 1000));
50 }
51}
52
53// Usage
54await rolloutController.gradualRollout(
55 'new-checkout',
56 [1, 5, 10, 25, 50, 100],
57 30 // 30 minutes between steps
58);A/B Testing Integration#
1// A/B test variant assignment
2interface ABTest {
3 name: string;
4 variants: {
5 name: string;
6 weight: number; // 0-100
7 }[];
8}
9
10function assignVariant(
11 test: ABTest,
12 userId: string
13): string {
14 const hash = hashUserId(userId, test.name);
15
16 let cumulative = 0;
17 for (const variant of test.variants) {
18 cumulative += variant.weight;
19 if (hash < cumulative) {
20 return variant.name;
21 }
22 }
23
24 return test.variants[test.variants.length - 1].name;
25}
26
27// Track conversions
28async function trackConversion(
29 testName: string,
30 userId: string,
31 conversionType: string
32): Promise<void> {
33 const variant = assignVariant(getTest(testName), userId);
34
35 await analytics.track('ab_test_conversion', {
36 testName,
37 variant,
38 userId,
39 conversionType,
40 timestamp: new Date(),
41 });
42}
43
44// Usage
45const variant = assignVariant(
46 {
47 name: 'checkout-button-color',
48 variants: [
49 { name: 'blue', weight: 50 },
50 { name: 'green', weight: 50 },
51 ],
52 },
53 userId
54);
55
56<Button color={variant}>Checkout</Button>Admin Dashboard#
1// API routes for flag management
2app.get('/api/admin/flags', requireAdmin, async (req, res) => {
3 const flags = await prisma.featureFlag.findMany({
4 orderBy: { name: 'asc' },
5 });
6 res.json(flags);
7});
8
9app.put('/api/admin/flags/:name', requireAdmin, async (req, res) => {
10 const { name } = req.params;
11 const { enabled, percentage, allowedUsers, allowedGroups } = req.body;
12
13 const oldFlag = await prisma.featureFlag.findUnique({
14 where: { name },
15 });
16
17 const newFlag = await prisma.featureFlag.update({
18 where: { name },
19 data: { enabled, percentage, allowedUsers, allowedGroups },
20 });
21
22 // Audit log
23 await prisma.featureFlagAudit.create({
24 data: {
25 flagName: name,
26 action: 'updated',
27 oldValue: oldFlag,
28 newValue: newFlag,
29 userId: req.user.id,
30 },
31 });
32
33 // Invalidate cache
34 await redis.del(`flag:${name}`);
35
36 res.json(newFlag);
37});Best Practices#
DO:
✓ Use consistent naming (kebab-case)
✓ Set expiration dates for temporary flags
✓ Clean up old flags regularly
✓ Audit all flag changes
✓ Monitor feature performance
✓ Use flags for gradual rollouts
DON'T:
✗ Create flags without owners
✗ Leave flags in code indefinitely
✗ Use flags for configuration
✗ Nest too many flags
✗ Skip testing both branches
Flag Lifecycle#
1## Flag Lifecycle
2
31. **Create** - Define flag with owner
42. **Develop** - Implement feature behind flag
53. **Test** - QA both enabled/disabled states
64. **Deploy** - Ship to production (disabled)
75. **Rollout** - Gradually enable for users
86. **Monitor** - Watch metrics and errors
97. **Full Release** - Enable for everyone
108. **Cleanup** - Remove flag from code
119. **Archive** - Delete flag configurationConclusion#
Feature flags enable safe, gradual releases. Start simple with boolean toggles, add percentage rollouts for gradual releases, and integrate with monitoring for automatic rollbacks.
Clean up flags regularly—technical debt accumulates when flags live forever.