Feature Flags: Deploy Without Risk
Feature flags decouple deployment from release. Ship code any time, enable features when you're ready — and kill them instantly if something goes wrong.
The traditional deployment model has a dirty secret: the bigger your release, the bigger the risk. Teams bundle weeks of work into a release, push it all at once, and hold their breath. If something breaks, rollback means reverting everything.
Feature flags change the equation entirely. You deploy continuously but release deliberately.
What Is a Feature Flag?
A feature flag (also called a feature toggle or feature switch) is a conditional in your code that enables or disables functionality at runtime — without redeployment.
if (featureFlags.isEnabled('new-checkout-flow', userId)) {
return newCheckoutFlow(cart);
} else {
return legacyCheckoutFlow(cart);
}
The flag value comes from a config store, not from your code. Flip the flag, and the behavior changes — instantly, without touching the codebase or redeploying.
Types of Feature Flags
Not all flags are the same. They have different lifespans and purposes:
Release Flags
Short-lived flags that hide incomplete features during development. Once the feature is stable, the flag is removed.
// Enable for internal team only
if (flags.isEnabled('new-search-algorithm', { groups: ['internal'] })) {
return newSearch(query);
}
Experiment Flags (A/B Tests)
Control which users get which variant. Collect data, pick a winner, clean up.
const variant = flags.getVariant('checkout-button-color', userId);
return renderButton({ color: variant === 'A' ? 'blue' : 'green' });
Ops Flags
Kill switches for operational control. Disable expensive features under load, enable circuit breakers.
if (!flags.isEnabled('recommendations-engine')) {
return []; // Skip ML recommendations when under stress
}
Permission Flags
Long-lived flags tied to user tiers or roles. Feature X is only for premium users.
if (user.tier === 'premium' && flags.isEnabled('advanced-analytics', userId)) {
return premiumDashboard();
}
A Simple Implementation
You don’t need a third-party service to start. A basic flag store goes a long way:
interface FlagConfig {
enabled: boolean;
rolloutPercentage?: number; // 0-100
allowedUsers?: string[];
allowedGroups?: string[];
}
class FeatureFlagService {
private flags: Map<string, FlagConfig>;
constructor(private config: Record<string, FlagConfig>) {
this.flags = new Map(Object.entries(config));
}
isEnabled(flagName: string, context?: { userId?: string; groups?: string[] }): boolean {
const flag = this.flags.get(flagName);
if (!flag || !flag.enabled) return false;
// Check user allowlist
if (context?.userId && flag.allowedUsers?.includes(context.userId)) {
return true;
}
// Check group allowlist
if (context?.groups && flag.allowedGroups) {
if (context.groups.some((g) => flag.allowedGroups!.includes(g))) {
return true;
}
}
// Percentage rollout (deterministic by userId)
if (flag.rolloutPercentage !== undefined && context?.userId) {
const hash = simpleHash(flagName + context.userId);
return hash % 100 < flag.rolloutPercentage;
}
return flag.enabled;
}
}
function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
Load flags from a JSON file or database, and reload them periodically — no redeploy needed.
Gradual Rollouts
The killer feature of flags is controlled rollout. Instead of releasing to 100% of users at once, you ramp up gradually:
const flagConfig = {
'new-checkout-flow': {
enabled: true,
rolloutPercentage: 5, // Start with 5%
},
};
// Later: bump to 25%, then 50%, then 100%
// If something breaks: set to 0 instantly
You catch bugs early, limit blast radius, and build confidence before full release.
Production-Grade: Use a Flag Service
For teams beyond a few developers, managed flag services are worth it:
- LaunchDarkly: industry standard, real-time flag updates, rich targeting
- Unleash: open source, self-hostable
- Flagsmith: open source with a hosted option
- PostHog: combines flags with analytics and session recording
- AWS AppConfig / Azure App Configuration: cloud-native options
These services give you a UI for non-engineers to flip flags, audit logs, and SDKs for every language.
import LaunchDarkly from 'launchdarkly-node-server-sdk';
const client = LaunchDarkly.init(process.env.LD_SDK_KEY!);
await client.waitForInitialization();
const isEnabled = await client.variation(
'new-checkout-flow',
{ key: userId },
false // default value
);
Common Pitfalls
Flag debt
The biggest practical problem. Flags accumulate and nobody cleans them up. Set a TTL when you create a flag, and add a ticket to remove it after the rollout is complete.
// Add this comment so it's searchable
// TODO: Remove flag 'new-checkout-flow' after 2026-04-15
if (flags.isEnabled('new-checkout-flow')) { ... }
Testing complexity
Every flag doubles the number of code paths. Don’t test every combination — test flag-on and flag-off separately, and test the flag evaluation logic itself.
Nesting flags
Nested flags (if (flagA && flagB)) create exponential complexity. Keep flags independent.
Coupling flags to business logic
Flags should wrap features, not bleed into domain logic. If your Order class has flag checks inside it, you’ve gone too far.
Feature Flags and Trunk-Based Development
Feature flags are the missing piece that makes trunk-based development practical at scale. All developers commit to main. Long-lived feature branches disappear. Work-in-progress is hidden behind flags, not in branches.
The result: continuous integration without continuous deployment of unfinished features.
Key Takeaways
- Feature flags decouple deployment from release — ship code, choose when it goes live
- Different flag types have different lifespans: release, experiment, ops, permission
- Gradual rollouts limit blast radius and build confidence
- Clean up flags aggressively — flag debt is real technical debt
- At scale, use a managed service (LaunchDarkly, Unleash, Flagsmith)