Feature Flags: Implementation Guide

Type: Software Reference Confidence: 0.90 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

Flag TypeComplexityTypical LifetimeUse CaseCleanup
Release ToggleLowDays to weeksGradually roll out a new feature to usersRemove after 100% rollout confirmed stable
Experiment ToggleMediumWeeks to monthsA/B test two variants and measure outcomesRemove after experiment concludes and winner ships
Ops ToggleLowPermanent or long-livedCircuit breaker, graceful degradation under loadKeep as long as operational risk exists
Permission ToggleMediumMonths to yearsGate premium features by user tier/planKeep for lifetime of pricing model
Kill SwitchLowPermanentEmergency disable of non-critical features under loadKeep permanently -- safety mechanism
Canary FlagMediumHours to daysRoute 1-5% of traffic to new code pathRemove after canary period passes
Trunk-Based Dev FlagLowDaysHide incomplete code on main branchRemove when feature is complete
Migration FlagMediumWeeksSwitch between old and new data pathRemove after migration verified

[src1]

Decision Tree

START
|-- Need to gradually roll out a new feature?
|   |-- YES --> Use a Release Toggle with percentage rollout (see Step 2)
|   +-- NO |
|-- Need to run an experiment (A/B test)?
|   |-- YES --> Use an Experiment Toggle with user segmentation
|   +-- NO |
|-- Need emergency disable capability?
|   |-- YES --> Use a Kill Switch (permanent, simple boolean)
|   +-- NO |
|-- Need to gate features by user plan/tier?
|   |-- YES --> Use a Permission Toggle (long-lived, user-attribute based)
|   +-- NO |
|-- Need to degrade non-critical services under load?
|   |-- YES --> Use an Ops Toggle (circuit breaker pattern)
|   +-- NO |
+-- DEFAULT --> Use a Release Toggle for trunk-based development

Step-by-Step Guide

1. Define a flag evaluation interface

Create a clean abstraction so your business logic does not depend on a specific flag provider. This lets you swap between custom implementations and managed services. [src2]

// flag-service.ts -- minimal interface for flag evaluation
interface FlagService {
  isEnabled(flagKey: string, context?: FlagContext): boolean;
  getVariant(flagKey: string, context?: FlagContext): string;
}

interface FlagContext {
  userId?: string;
  email?: string;
  country?: string;
  plan?: string;
  percentageSeed?: number;  // stable hash for consistent bucketing
}

Verify: Ensure your interface supports boolean flags and multi-variant flags with user context.

2. Implement percentage rollout

Use consistent hashing so the same user always sees the same variant. Never use Math.random() -- it causes flickering between page loads. [src7]

// percentage-rollout.ts
import { createHash } from 'crypto';

function isEnabledForPercentage(
  flagKey: string, userId: string, percentage: number
): boolean {
  const hash = createHash('md5')
    .update(`${flagKey}:${userId}`).digest('hex');
  const bucket = parseInt(hash.substring(0, 8), 16) % 100;
  return bucket < percentage;
}

Verify: Call isEnabledForPercentage('flag', 'user-123', 50) multiple times -- it must return the same value every time.

3. Add targeting rules

Layer targeting rules on top of percentage rollout for fine-grained control. Evaluate rules in priority order: explicit overrides first, then segments, then percentage. [src5]

function evaluateFlag(config: FlagConfig, ctx: FlagContext): boolean {
  if (!config.enabled) return false;
  if (ctx.userId && ctx.userId in config.overrides) {
    return config.overrides[ctx.userId];
  }
  for (const rule of config.rules) {
    if (matchesRule(rule, ctx)) return rule.enabled;
  }
  if (ctx.userId) {
    return isEnabledForPercentage(config.key, ctx.userId, config.percentage);
  }
  return false;
}

Verify: Test with an overridden user, a rule-matched user, and a percentage-only user.

4. Implement flag cleanup automation

Set expiration dates at creation time and enforce them in CI. This is the most important step -- skipping it guarantees flag debt. [src4]

function checkExpiredFlags(registry: FlagRegistration[]): string[] {
  const now = new Date();
  return registry
    .filter(f => f.expiresAt && f.expiresAt < now)
    .map(f => `EXPIRED: ${f.key} (owner: ${f.owner})`);
}
// CI pipeline: fail build if checkExpiredFlags() returns non-empty

Verify: checkExpiredFlags(registry) should return an empty array in a healthy codebase.

Code Examples

Node.js: Custom Feature Flag Service with Redis

// Input:  Redis connection URL, flag configs stored as JSON
// Output: Boolean flag evaluation with percentage rollout

const Redis = require('ioredis');       // [email protected]
const crypto = require('crypto');

class FeatureFlagService {
  constructor(redisUrl) {
    this.redis = new Redis(redisUrl);
    this.cache = new Map();             // local cache, 30s TTL
  }

  async isEnabled(flagKey, userId, fallback = false) {
    try {
      const config = await this.getConfig(flagKey);
      if (!config || !config.enabled) return false;
      if (config.overrides?.[userId] !== undefined) {
        return config.overrides[userId];
      }
      if (config.percentage < 100) {
        const hash = crypto.createHash('md5')
          .update(`${flagKey}:${userId}`).digest('hex');
        return (parseInt(hash.slice(0, 8), 16) % 100) < config.percentage;
      }
      return true;
    } catch (err) {
      return fallback;  // always return fallback on error
    }
  }
}

Python: Custom Feature Flag with Database Backend

# Input:  PostgreSQL connection, flag configs in flags table
# Output: Boolean flag evaluation with user targeting

import hashlib
from typing import Optional
import psycopg2  # psycopg2-binary==2.9.x

class FeatureFlagService:
    def __init__(self, dsn: str):
        self.conn = psycopg2.connect(dsn)

    def is_enabled(self, flag_key: str, user_id: str, fallback: bool = False) -> bool:
        try:
            config = self._get_config(flag_key)
            if not config or not config["enabled"]:
                return False
            if user_id in config.get("overrides", {}):
                return config["overrides"][user_id]
            pct = config.get("percentage", 100)
            if pct < 100:
                h = hashlib.md5(f"{flag_key}:{user_id}".encode()).hexdigest()
                return int(h[:8], 16) % 100 < pct
            return True
        except Exception:
            return fallback

Go: Feature Flag Middleware for HTTP

// Input:  Flag config map, HTTP request with user context
// Output: HTTP middleware that injects flag values into request context

func (fs *FlagService) IsEnabled(flagKey, userID string) bool {
    config, ok := fs.flags[flagKey]
    if !ok || !config.Enabled { return false }
    if val, exists := config.Overrides[userID]; exists { return val }
    if config.Percentage < 100 {
        hash := md5.Sum([]byte(flagKey + ":" + userID))
        bucket := binary.BigEndian.Uint32(hash[:4]) % 100
        return int(bucket) < config.Percentage
    }
    return true
}

Node.js: Unleash SDK Integration

// Input:  Unleash server URL, API token
// Output: Feature flag evaluation via managed service

const { initialize } = require('unleash-client');  // [email protected]

const unleash = initialize({
  url: 'https://unleash.example.com/api',
  appName: 'my-app',
  customHeaders: { Authorization: process.env.UNLEASH_API_KEY },
  refreshInterval: 15000,
});

unleash.on('ready', () => {
  const context = { userId: user.id, properties: { plan: user.plan } };
  if (unleash.isEnabled('premium-feature', context)) {
    enablePremiumFeature();
  }
});

Anti-Patterns

Wrong: Feature flag wrapping deeply nested code

// BAD -- flag check buried inside business logic
function processOrder(order) {
  validateItems(order.items);
  if (featureFlags.isEnabled('new-shipping')) {  // buried deep
    applyNewShipping(order);
    if (featureFlags.isEnabled('new-tracking')) {  // nested flags!
      enableTracking(order);
    }
  } else { applyOldShipping(order); }
}

Correct: Feature flag evaluated once at the boundary

// GOOD -- evaluate at the entry point, pass result down
app.post('/orders', (req, res) => {
  const features = {
    useNewShipping: flags.isEnabled('new-shipping', req.user.id),
  };
  processOrder(req.body, features);
});

[src1]

Wrong: No expiration tracking on release flags

// BAD -- flag with no lifecycle management
await redis.set('flag:new-dashboard', JSON.stringify({
  enabled: true, percentage: 100,
  // No owner, no expiration -- lives forever
}));

Correct: Flag with lifecycle metadata

// GOOD -- every flag has an owner and expiration
await redis.set('flag:new-dashboard', JSON.stringify({
  enabled: true, percentage: 100,
  owner: 'frontend-team', createdAt: '2026-02-01',
  expiresAt: '2026-03-15', jiraTicket: 'FE-4521', type: 'release',
}));

[src4]

Wrong: Using Math.random() for percentage rollout

# BAD -- random gives different results on every evaluation
import random
def is_enabled(flag_key, user_id, percentage):
    return random.random() * 100 < percentage  # flickers every request!

Correct: Stable hash-based bucketing

# GOOD -- same user always gets same bucket
import hashlib
def is_enabled(flag_key, user_id, percentage):
    h = hashlib.md5(f"{flag_key}:{user_id}".encode()).hexdigest()
    return int(h[:8], 16) % 100 < percentage  # deterministic per user

[src7]

Wrong: Flag dependencies without explicit ordering

// BAD -- flag B depends on flag A, but no explicit relationship
if (flags.isEnabled('new-api') && flags.isEnabled('new-api-v2')) {
  // Undefined behavior if new-api is off but new-api-v2 is on
}

Correct: Explicit flag dependency declaration

// GOOD -- declare dependencies in flag config
const flagConfig = {
  'new-api-v2': {
    enabled: true, dependsOn: ['new-api'], percentage: 50,
  },
};

[src2]

Common Pitfalls

Diagnostic Commands

# List all feature flags in a Redis-backed system
redis-cli KEYS "flag:*" | while read key; do echo "$key: $(redis-cli GET $key)"; done

# Find all flag references in codebase (grep for cleanup)
grep -rn "isEnabled\|is_enabled\|IsEnabled\|feature_flag" --include="*.ts" --include="*.py" --include="*.go" src/

# Check for expired flags in a JSON config file
cat flags.json | python3 -c "
import json, sys
from datetime import datetime
flags = json.load(sys.stdin)
now = datetime.now().isoformat()[:10]
for k, v in flags.items():
    exp = v.get('expiresAt')
    if exp and exp < now:
        print(f'EXPIRED: {k} (expired {exp}, owner: {v.get(\"owner\", \"unknown\")})')
"

Version History & Compatibility

TechnologyStatusKey FeatureNotes
OpenFeature v0.7Current spec (2024)Vendor-neutral SDK interfaceAdopt for new projects to avoid vendor lock-in
LaunchDarkly SDK v8CurrentStreaming updates, local evaluationIndustry leader. Free tier: 1K MAU
Unleash v5CurrentSelf-hosted OSS, Prometheus metricsBest open-source option. Pro tier for RBAC
Flagsmith v2CurrentOpen-source, edge evaluationGood for self-hosted with edge requirements
GrowthBook v2CurrentStatistics engine built-inBest when A/B testing is primary use case
Custom (DB/Redis)EvergreenFull control, no vendor dependencyGood for <50 flags, becomes painful at scale

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Gradually rolling out features to reduce blast radiusToggling between dev/staging/prod environmentsEnvironment variables or config management
Running A/B experiments on UI or backend behaviorPermanently gating features by user role/planRBAC or entitlement system
Need emergency kill switch for non-critical servicesConfiguring infrastructure (ports, URLs, timeouts)Infrastructure-as-code (Terraform, Helm)
Hiding incomplete features on main branch (trunk-based dev)Simple on/off that never changes after deployHard-code or compile-time flag
Canary releases -- test with 1% traffic before full rolloutFewer than 3 developers or no CI/CD pipelineShip directly, iterate fast
Decoupling deploy from release in regulated environmentsFeature is security-critical (auth, encryption)Code review + staged deployment instead

Important Caveats

Related Units