Feature Flags for Safe Deployments

Feature flags (also called feature toggles) are a powerful technique that allows you to deploy code to production without immediately releasing it to users. This separation of deployment from release is fundamental to modern software delivery, enabling safe rollouts, A/B testing, and instant rollbacks.

For legacy modernization, feature flags are essential—they allow you to ship new code behind a toggle, gradually enable it for users, and instantly disable it if problems arise.

Video Summary

The video presentation covers feature flags comprehensively:

  • The difference between deployment and release
  • Types of feature flags and when to use each
  • Implementing feature flags in legacy systems
  • Gradual rollout strategies and targeting rules
  • Managing flag lifecycle to prevent flag debt
  • Integration with monitoring and incident response
  • Tools and frameworks for feature flag management

Key Concepts

1. Decouple Deployment from Release

Traditional approach:

  • Code merged → Code deployed → Feature live for all users
  • Risky: Problems affect everyone immediately
  • Stressful: Deployments are high-stakes events

With feature flags:

  • Code merged → Code deployed behind flag → Feature gradually enabled
  • Safe: Problems affect small percentage initially
  • Calm: Deployments are low-risk, frequent events
function checkout(order: Order) {
  if (featureFlags.isEnabled('new-checkout-flow')) {
    return newCheckoutFlow(order);
  }
  return legacyCheckoutFlow(order);
}

2. Types of Feature Flags

Release Flags (Short-lived)

  • Purpose: Gradually roll out new features
  • Lifetime: Days to weeks
  • Example: Enabling new UI for percentage of users
if (featureFlags.isEnabled('react-dashboard', userId)) {
  return <ReactDashboard />;
}
return <LegacyDashboard />;

Experiment Flags (Short-lived)

  • Purpose: A/B testing and experiments
  • Lifetime: Duration of experiment
  • Example: Testing two checkout flows to see which converts better
const variant = featureFlags.getVariant('checkout-experiment', userId);
if (variant === 'new-flow') {
  return newCheckoutFlow(order);
}
return currentCheckoutFlow(order);

Ops Flags (Long-lived)

  • Purpose: Operational control (circuit breakers, rate limiting)
  • Lifetime: Permanent
  • Example: Disable expensive feature during high load
if (featureFlags.isEnabled('enable-recommendations')) {
  recommendations = await fetchRecommendations(userId);
}

Permission Flags (Long-lived)

  • Purpose: Control access to features by user role/plan
  • Lifetime: Permanent
  • Example: Premium features only for paid users
if (featureFlags.isEnabledForUser('advanced-analytics', user)) {
  return <AdvancedAnalytics />;
}
return <BasicAnalytics />;

3. Targeting and Rollout Strategies

Percentage Rollout

// Enable for 25% of users
if (featureFlags.isEnabledForPercentage('new-feature', userId, 25)) {
  return newImplementation();
}

User Targeting

// Enable for specific users (internal testing)
if (featureFlags.isEnabledForUsers('beta-feature', userId, [
  'user123',
  'user456',
])) {
  return betaFeature();
}

Attribute-Based Targeting

// Enable for users in specific regions
if (featureFlags.isEnabledForAttributes('eu-compliance', {
  userId,
  region: user.region,
  isPremium: user.plan === 'premium',
})) {
  return gdprCompliantFlow();
}

Ring Deployment

// Progressive rollout by ring
const ring = getUserRing(userId);

if (ring === 'internal' ||
    ring === 'alpha' ||
    (ring === 'beta' && featureFlags.isEnabled('beta-rollout')) ||
    (ring === 'production' && featureFlags.isEnabled('production-rollout'))) {
  return newFeature();
}

4. Feature Flag Lifecycle

Creation → Rollout → Cleanup

  1. Create flag - Default to OFF
  2. Test internally - Enable for dev team
  3. Alpha rollout - 5% of users
  4. Monitor metrics - Error rates, performance, business KPIs
  5. Expand rollout - 25%, 50%, 75%, 100%
  6. Cleanup - Remove flag after 100% for 2 weeks

Critical: Clean up old flags to avoid flag debt!

Real-World Applications

Example 1: Legacy Database Migration

class UserRepository {
  constructor(
    private mysql: MySQLUserRepository,
    private postgres: PostgresUserRepository,
    private featureFlags: FeatureFlags
  ) {}

  async getUser(id: string): Promise<User> {
    // Gradual migration from MySQL to Postgres
    const rolloutPercentage = this.featureFlags.getRolloutPercentage(
      'postgres-migration'
    );

    const usePostgres = this.shouldUsePostgres(id, rolloutPercentage);

    if (usePostgres) {
      metrics.increment('db.postgres.read');
      return this.postgres.getUser(id);
    }

    metrics.increment('db.mysql.read');
    return this.mysql.getUser(id);
  }

  private shouldUsePostgres(id: string, percentage: number): boolean {
    // Consistent hashing ensures same user always uses same DB
    const hash = this.hashUserId(id);
    return (hash % 100) < percentage;
  }

  private hashUserId(id: string): number {
    // Simple hash for consistent user assignment
    return id.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
  }
}

Example 2: API Version Migration

class APIRouter {
  async handleRequest(req: Request): Promise<Response> {
    const apiVersion = this.featureFlags.getStringValue(
      'api-version',
      req.user.id,
      'v1'
    );

    switch (apiVersion) {
      case 'v2':
        return this.v2Handler.handle(req);
      case 'v1':
      default:
        return this.v1Handler.handle(req);
    }
  }
}

// Gradual migration:
// Week 1: 5% on v2
// Week 2: 25% on v2
// Week 3: 50% on v2
// Week 4: 100% on v2
// Week 6: Remove v1 code

Example 3: Circuit Breaker for Legacy System

class PaymentService {
  async processPayment(payment: Payment): Promise<PaymentResult> {
    // Ops flag: disable payments if legacy system is struggling
    if (!this.featureFlags.isEnabled('enable-payment-processing')) {
      throw new ServiceUnavailableError('Payments temporarily disabled');
    }

    try {
      return await this.legacyPaymentSystem.process(payment);
    } catch (error) {
      // Auto-disable if error rate too high
      await this.circuitBreaker.recordFailure();
      throw error;
    }
  }
}

Example 4: Kill Switch for Expensive Features

class RecommendationEngine {
  async getRecommendations(userId: string): Promise<Product[]> {
    // Kill switch for expensive ML recommendations during high traffic
    if (!this.featureFlags.isEnabled('ml-recommendations')) {
      // Fallback to simple rule-based recommendations
      return this.ruleBasedRecommendations(userId);
    }

    return this.mlRecommendations(userId);
  }
}

Common Pitfalls

1. Flag Debt (Abandoned Flags)

Problem: Flags created but never cleaned up, creating unmaintainable code.

// Code becomes unreadable with nested flags
if (flags.enabled('flag-a')) {
  if (flags.enabled('flag-b')) {
    if (flags.enabled('flag-c')) {
      // What is this code for?
    }
  }
}

Solution: Flag lifecycle policy and automated cleanup:

// Add creation date to flags
const FLAG_METADATA = {
  'new-checkout': {
    created: '2024-01-01',
    type: 'release',
    owner: 'checkout-team',
    cleanupAfter: '2024-02-01',
  },
};

// Alert on old flags
function auditFlags() {
  const now = new Date();
  Object.entries(FLAG_METADATA).forEach(([name, metadata]) => {
    if (now > new Date(metadata.cleanupAfter)) {
      alerting.warn(`Flag ${name} should be cleaned up`);
    }
  });
}

2. Testing Complexity

Problem: Need to test all flag combinations.

With 3 flags, there are 2³ = 8 combinations to test!

Solution: Test critical paths and use contract tests:

// Test both paths
describe('Checkout', () => {
  it('should work with new flow', () => {
    featureFlags.enable('new-checkout');
    const result = checkout(order);
    expect(result.status).toBe('success');
  });

  it('should work with legacy flow', () => {
    featureFlags.disable('new-checkout');
    const result = checkout(order);
    expect(result.status).toBe('success');
  });
});

3. Performance Overhead

Problem: Checking flags on every request adds latency.

Solution: Cache flag values:

class FeatureFlagCache {
  private cache = new Map<string, boolean>();
  private cacheExpiry = new Map<string, number>();

  async isEnabled(flag: string, userId: string): Promise<boolean> {
    const cacheKey = `${flag}:${userId}`;
    const expiry = this.cacheExpiry.get(cacheKey);

    // Return cached value if fresh
    if (expiry && Date.now() < expiry) {
      return this.cache.get(cacheKey)!;
    }

    // Fetch and cache
    const value = await this.featureFlagService.isEnabled(flag, userId);
    this.cache.set(cacheKey, value);
    this.cacheExpiry.set(cacheKey, Date.now() + 60000); // 1 min cache

    return value;
  }
}

4. Flag State Inconsistency

Problem: User experiences flag toggling mid-session.

Solution: Pin flag state at session start:

class SessionFeatureFlags {
  private sessionFlags = new Map<string, boolean>();

  async initializeSession(userId: string) {
    // Evaluate all flags once at session start
    const flags = await this.featureFlagService.getAllFlags(userId);
    flags.forEach((value, name) => {
      this.sessionFlags.set(name, value);
    });
  }

  isEnabled(flag: string): boolean {
    return this.sessionFlags.get(flag) ?? false;
  }
}

Implementing Feature Flags

Simple In-Memory Implementation

class SimpleFeatureFlags {
  private flags = new Map<string, boolean>();

  enable(flag: string) {
    this.flags.set(flag, true);
  }

  disable(flag: string) {
    this.flags.set(flag, false);
  }

  isEnabled(flag: string): boolean {
    return this.flags.get(flag) ?? false;
  }
}

Percentage Rollout

class PercentageFeatureFlags {
  private rollouts = new Map<string, number>();

  setRolloutPercentage(flag: string, percentage: number) {
    this.rollouts.set(flag, percentage);
  }

  isEnabled(flag: string, userId: string): boolean {
    const percentage = this.rollouts.get(flag) ?? 0;
    const hash = this.hashString(userId);
    return (hash % 100) < percentage;
  }

  private hashString(str: string): number {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
      hash = (hash << 5) - hash + str.charCodeAt(i);
      hash = hash & hash; // Convert to 32-bit integer
    }
    return Math.abs(hash);
  }
}

Production-Ready Solutions

For production use, consider established tools:

  • LaunchDarkly - Full-featured SaaS
  • Unleash - Open-source, self-hosted
  • Flagsmith - Open-source with SaaS option
  • Split.io - Enterprise feature flagging and experimentation

Key Takeaways

  • Feature flags decouple deployment from release, enabling safer rollouts
  • Different flag types (release, experiment, ops, permission) serve different purposes
  • Gradual rollout strategies minimize blast radius of changes
  • Clean up short-lived flags promptly to avoid technical debt
  • Flags are essential for legacy modernization, enabling reversible changes
  • Use production-ready flag management tools for complex requirements
  • Monitor flag impact on performance and user experience

Further Reading