Feature Flags: A Practical Guide for Developers
You’ve built a new feature. It’s tested, reviewed, and ready to ship. But you’re nervous—what if something goes wrong in production? What if users hate it?
Feature flags let you deploy code without exposing it to users, then gradually roll it out while monitoring for problems. In this guide, we’ll learn how to implement and use feature flags effectively.
What are feature flags?
A feature flag (also called feature toggle) is a conditional that controls whether a piece of code runs:
if (featureFlags.get('new-checkout')) {
showNewCheckout()
} else {
showOldCheckout()
}The flag’s value is stored externally (in a config file, database, or remote service), so you can change it without deploying code.
This simple concept enables powerful workflows:
- Dark launches: Deploy code that’s hidden from users
- Gradual rollouts: Enable features for 1%, then 10%, then 100%
- Kill switches: Instantly disable broken features
- A/B testing: Show different variants to different users
Types of feature flags
Boolean flags
The simplest type—on or off:
if (flags.get('dark-mode-enabled')) {
applyDarkTheme()
}Percentage rollouts
Enable a feature for a percentage of users:
// 10% of users see the new feature
if (flags.get('new-pricing', { context: { userId: user.id } })) {
showNewPricing()
}The flag system uses the user ID to deterministically decide whether this user is in the 10%. The same user always gets the same result.
User targeting
Enable features for specific users or groups:
// Only premium users get this feature
if (flags.get('advanced-analytics', { context: { plan: user.plan } })) {
showAdvancedAnalytics()
}Or target specific users by ID:
// Beta testers only
if (flags.get('experimental-feature', { context: { userId: user.id } })) {
showExperimentalFeature()
}Which type of feature flag would you use to test a new checkout flow with 5% of users?
Implementing feature flags
Simple in-memory implementation
For small projects, you can start with a simple object:
const FEATURE_FLAGS = {
'new-checkout': false,
'dark-mode': true,
'experimental-api': false,
}
function isEnabled(flagName) {
return FEATURE_FLAGS[flagName] ?? false
}This works, but changes require code changes and redeployment.
File-based configuration
Store flags in a JSON file:
import { readFileSync } from 'fs'
function loadFlags() {
const content = readFileSync('flags.json', 'utf-8')
return JSON.parse(content)
}
function isEnabled(flagName) {
const flags = loadFlags()
return flags[flagName] ?? false
}Better—you can change flags without changing code. But you still need to restart the application to pick up changes.
Remote configuration
For production systems, use a remote configuration service that pushes updates in real-time:
import { Replane } from '@replanejs/sdk'
interface Configs {
'new-checkout': boolean
'advanced-analytics': boolean
}
const replane = new Replane<Configs>()
await replane.connect({
sdkKey: process.env.REPLANE_SDK_KEY!,
baseUrl: 'https://cloud.replane.dev',
})
// Flags update in real-time, no restart needed
function isEnabled(flagName: keyof Configs, context?: Record<string, unknown>): boolean {
if (context) {
return replane.get(flagName, { context })
}
return replane.get(flagName)
}With remote configuration, you can change flags in a dashboard and have changes take effect across all servers in seconds.
Percentage rollouts in detail
Percentage rollouts need to be deterministic—the same user should always get the same result. Here’s how to implement this:
import { createHash } from 'crypto'
function isInRollout(userId: string, flagName: string, percentage: number): boolean {
// Create a hash of userId + flagName
const key = `${userId}:${flagName}`
const hash = createHash('md5').update(key).digest('hex')
// Convert first 8 hex chars to number, then to 0-100 range
const bucket = parseInt(hash.substring(0, 8), 16) % 100
return bucket < percentage
}Using it:
// 10% rollout of new-checkout
if (isInRollout(user.id, 'new-checkout', 10)) {
showNewCheckout()
} else {
showOldCheckout()
}The hash ensures:
- Same user + same flag = same result (deterministic)
- Different flags give different results (independent)
- Distribution is roughly uniform
Most feature flag services handle this automatically. For example, with Replane, you configure percentage rollouts in the dashboard and the SDK handles the math.
Why is it important for percentage rollouts to be deterministic?
Best practices
Name flags clearly
Use descriptive names that explain what the flag does:
// Good
'enable-new-checkout-flow'
'show-premium-features'
'use-v2-recommendation-engine'
// Bad
'flag1'
'test'
'new_thing'Set expiration dates
Feature flags are temporary. When a feature is fully rolled out, remove the flag:
// This flag has been at 100% for 3 months
// Time to remove it and clean up the code
if (flags.get('new-checkout')) { // DELETE THIS
showNewCheckout()
} else {
showOldCheckout() // DELETE THIS
}
// Becomes simply:
showNewCheckout()Track flag creation dates and set reminders to clean up.
Use defaults wisely
Always have a safe default in case the flag service is unavailable:
// Good: safe default
const newFeatureEnabled = flags.get('risky-new-feature', { default: false })
// Bad: no default, might throw
const newFeatureEnabled = flags.get('risky-new-feature')For risky features, the default should be false. For well-established features behind flags, the default might be true.
Log flag evaluations
When debugging, you need to know which flags were active:
function getFlag(name, context) {
const value = flags.get(name, { context })
logger.debug(`Flag ${name} = ${value} for context ${JSON.stringify(context)}`)
return value
}Keep flag checks simple
Avoid complex logic around flags:
// Bad: nested flags are confusing
if (flags.get('feature-a')) {
if (flags.get('feature-b')) {
doSomething()
} else if (flags.get('feature-c')) {
doSomethingElse()
}
}
// Better: one flag, clear behavior
if (flags.get('feature-a-with-b')) {
doSomething()
}Common patterns
Kill switch
A flag that’s normally true but can be turned false to disable a feature:
if (flags.get('payments-enabled', { default: true })) {
processPayment()
} else {
showMaintenanceMessage()
}If payments break at 2am, flip the switch and users see a friendly message instead of errors.
Ops flags vs release flags
Separate operational flags from release flags:
// Ops flag: controls behavior, long-lived
const rateLimit = flags.get('api-rate-limit')
// Release flag: temporary, remove after rollout
if (flags.get('new-ui-enabled')) {
showNewUI()
}Ops flags stay forever. Release flags should be removed once the feature is fully rolled out.
Trunk-based development
Feature flags enable trunk-based development—everyone commits to main, but incomplete features are hidden behind flags:
main ──●──●──●──●──●──●──●──▶
│ │ │ │ │ │ │
Feature A (flagged off)
└──┴──┴──┴──┘
Feature B (flagged off)
└──┘This avoids long-lived feature branches and merge conflicts.
Tools for feature flags
You can build your own feature flag system, but existing tools save time:
- Replane — Open-source, self-hosted or cloud, supports JavaScript/Python/.NET
- LaunchDarkly — Enterprise feature management
- Unleash — Open-source feature toggles
- Flagsmith — Open-source with cloud option
For open-source and self-hosting, Replane is a solid choice with real-time updates via SSE and built-in version history.
Summary
Feature flags let you:
- Deploy code without exposing it to users
- Gradually roll out features to percentages of users
- Target specific users or groups
- Instantly disable broken features
Key practices:
- Name flags clearly
- Clean up old flags
- Use safe defaults
- Log flag evaluations
Exercises
-
Implement a flag system: Build a simple feature flag class that supports boolean flags and percentage rollouts.
-
Add targeting rules: Extend your flag system to support rules like “enable for premium users” or “enable for users in Europe”.
-
Build a flag dashboard: Create a simple web interface where you can view and toggle feature flags.
Discussion