You finished a feature. Its in main branch. But stakeholders arent ready to announce it. Or QA needs more time. Or you want to test with 1% of users first.
Feature flags let you deploy code without releasing it. The code is there, just hidden behind a boolean.
The Basic Idea
if (featureFlags.isEnabled('new-checkout-flow')) {
return <NewCheckout />;
} else {
return <OldCheckout />;
}
Simple but powerful. That one boolean controls whether users see new or old code.
Types of Feature Flags
Release flags: Hide incomplete features. Remove after launch.
Experiment flags: A/B tests. Remove after experiment concludes.
Ops flags: Kill switches. Keep permanently.
Permission flags: Control access to premium features.
Basic Implementation
class FeatureFlags {
private flags: Map<string, FlagConfig> = new Map();
isEnabled(flagName: string, context?: UserContext): boolean {
const flag = this.flags.get(flagName);
if (!flag) return false;
// Global kill switch
if (!flag.enabled) return false;
// Percentage rollout
if (flag.percentage < 100) {
const bucket = this.getUserBucket(context?.userId);
if (bucket > flag.percentage) return false;
}
// User allowlist
if (flag.allowlist?.includes(context?.userId)) {
return true;
}
return flag.enabled;
}
private getUserBucket(userId?: string): number {
if (!userId) return Math.random() * 100;
// Consistent hashing - same user always gets same bucket
return hashCode(userId) % 100;
}
}
Percentage Rollouts
Gradually expose features to more users:
// Start at 1%
await flags.update('new-search', { percentage: 1 });
// Monitor metrics, bump to 10%
await flags.update('new-search', { percentage: 10 });
// Looking good, go to 100%
await flags.update('new-search', { percentage: 100 });
// Remove flag entirely
The Kill Switch
When something goes wrong, turn it off instantly:
// In your admin panel or CLI
async function emergencyOff(flagName: string) {
await flags.update(flagName, { enabled: false });
await slack.notify(`🚨 ${flagName} disabled by kill switch`);
}
// In your monitoring
if (errorRate > threshold) {
await emergencyOff('new-payment-flow');
}
No deployment needed. No waiting for CI. Just flip the switch.
Clean Up Your Flags
Old flags become tech debt. Track them:
const flags = {
'new-checkout': {
enabled: true,
owner: 'payments-team',
createdAt: '2024-01-15',
expiresAt: '2024-03-15', // Remind to clean up
description: 'New checkout flow redesign'
}
};
// Alert when flags are stale
function checkStaleFlags() {
for (const [name, config] of Object.entries(flags)) {
if (new Date(config.expiresAt) < new Date()) {
alert(`Flag "${name}" has expired. Time to remove it!`);
}
}
}
Services To Consider
Rolling your own works for simple cases. For production:
- LaunchDarkly - Full featured, pricey
- Unleash - Open source option
- Flagsmith - Good middle ground
- PostHog - Comes with analytics
Common Pitfalls
1. Testing both paths
// Always test with flag on AND off
describe('checkout', () => {
it('works with new flow', () => {
mockFlags({ 'new-checkout': true });
// test new flow
});
it('works with old flow', () => {
mockFlags({ 'new-checkout': false });
// test old flow
});
});
2. Flag nesting gets messy
// Avoid this nightmare
if (flags.isEnabled('featureA')) {
if (flags.isEnabled('featureB')) {
if (flags.isEnabled('featureC')) {
// 8 possible combinations to test 😱
}
}
}
3. Forgetting to remove flags
Set calendar reminders. Add expiry dates. Run audits monthly.
Further Reading
Feature flags give you control. Deploy whenever. Release whenever. Roll back instantly. Just dont forget to clean up after yourself.
