Skip to main content

Command Palette

Search for a command to run...

Feature Flag Implementation: Progressive Rollouts

A/B testing and canary releases with LaunchDarkly alternatives

Published
8 min read
T

Welcome to TopperBlog! 👋

I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.

🎯 What I Write About: • AI/ML Engineering & LLMs • Web3 & Blockchain Development
• System Design & Architecture • Interview Preparation (FAANG) • Freelancing & Remote Work • Modern Tech Stacks (Next.js, React, Rust, TypeScript) • Performance Optimization & Best Practices

💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.

📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.

🌐 Let's connect and grow together in this amazing tech journey!

#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering

Content Role: pillar

Feature Flag Implementation: Progressive Rollouts

A/B testing and canary releases with LaunchDarkly alternatives

Progressive rollouts have become essential for modern software deployment. The ability to release features gradually, test with specific user segments, and roll back instantly when issues arise separates resilient systems from fragile ones. Feature flags—also called feature toggles—enable this capability, but implementing them correctly requires understanding both the technical architecture and operational patterns.

The Problem with Traditional Deployments

Traditional all-or-nothing deployments create unnecessary risk. When you deploy a new feature to all users simultaneously, you expose your entire user base to potential bugs, performance issues, or unexpected behavior. The blast radius of a problem becomes your entire system.

Consider a payment processing feature with a subtle bug that only manifests under specific conditions. In a traditional deployment, you discover this bug when thousands of users encounter failed transactions. By then, customer support is overwhelmed, revenue is lost, and your team scrambles to roll back.

Progressive rollouts solve this by decoupling deployment from release. You deploy code to production but control who sees it through feature flags. This separation enables:

  • Gradual exposure: Release to 1% of users, then 5%, then 25%, monitoring metrics at each stage
  • Targeted testing: Show features to internal users, beta testers, or specific customer segments
  • Instant rollback: Disable a feature without redeploying code
  • A/B testing: Compare feature variants to measure impact on key metrics
  • Canary releases: Test with a small subset before full rollout

Architecture Fundamentals

A robust feature flag system requires three core components: flag evaluation, configuration management, and context handling.

Flag Evaluation Engine

The evaluation engine determines whether a flag is enabled for a given context. Here's a TypeScript implementation:

interface FlagContext {
  userId: string;
  email?: string;
  attributes?: Record<string, any>;
}

interface FlagRule {
  attribute: string;
  operator: 'equals' | 'contains' | 'in' | 'gt' | 'lt';
  value: any;
}

interface Flag {
  key: string;
  enabled: boolean;
  rolloutPercentage?: number;
  rules?: FlagRule[];
  variants?: Record<string, any>;
}

class FeatureFlagEngine {
  private flags: Map<string, Flag> = new Map();

  evaluate(flagKey: string, context: FlagContext): boolean {
    const flag = this.flags.get(flagKey);
    if (!flag) return false;
    if (!flag.enabled) return false;

    // Check targeting rules first
    if (flag.rules && !this.evaluateRules(flag.rules, context)) {
      return false;
    }

    // Apply percentage rollout
    if (flag.rolloutPercentage !== undefined) {
      return this.isInRollout(context.userId, flagKey, flag.rolloutPercentage);
    }

    return true;
  }

  private evaluateRules(rules: FlagRule[], context: FlagContext): boolean {
    return rules.every(rule => {
      const value = context.attributes?.[rule.attribute];

      switch (rule.operator) {
        case 'equals':
          return value === rule.value;
        case 'contains':
          return String(value).includes(rule.value);
        case 'in':
          return Array.isArray(rule.value) && rule.value.includes(value);
        case 'gt':
          return Number(value) > Number(rule.value);
        case 'lt':
          return Number(value) < Number(rule.value);
        default:
          return false;
      }
    });
  }

  private isInRollout(userId: string, flagKey: string, percentage: number): boolean {
    // Consistent hashing ensures same user always gets same result
    const hash = this.hashString(`${userId}:${flagKey}`);
    return (hash % 100) < percentage;
  }

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

  setFlag(flag: Flag): void {
    this.flags.set(flag.key, flag);
  }
}

Progressive Rollout Implementation

Progressive rollouts require careful orchestration. Here's a complete implementation with monitoring:

interface RolloutStage {
  percentage: number;
  durationMinutes: number;
  successCriteria: {
    errorRateThreshold: number;
    latencyP95Threshold: number;
  };
}

class ProgressiveRolloutManager {
  private engine: FeatureFlagEngine;
  private metrics: MetricsCollector;

  constructor(engine: FeatureFlagEngine, metrics: MetricsCollector) {
    this.engine = engine;
    this.metrics = metrics;
  }

  async executeRollout(
    flagKey: string,
    stages: RolloutStage[]
  ): Promise<void> {
    for (const stage of stages) {
      console.log(`Rolling out ${flagKey} to ${stage.percentage}%`);

      // Update flag configuration
      const flag = this.engine['flags'].get(flagKey);
      if (flag) {
        flag.rolloutPercentage = stage.percentage;
        this.engine.setFlag(flag);
      }

      // Wait for stage duration
      await this.wait(stage.durationMinutes * 60 * 1000);

      // Evaluate metrics
      const metrics = await this.metrics.getMetrics(flagKey, stage.durationMinutes);

      if (!this.meetsSuccessCriteria(metrics, stage.successCriteria)) {
        console.error(`Stage failed criteria. Rolling back ${flagKey}`);
        await this.rollback(flagKey);
        throw new Error(`Rollout failed at ${stage.percentage}%`);
      }
    }

    console.log(`Successfully rolled out ${flagKey} to 100%`);
  }

  private meetsSuccessCriteria(
    metrics: any,
    criteria: RolloutStage['successCriteria']
  ): boolean {
    return (
      metrics.errorRate <= criteria.errorRateThreshold &&
      metrics.latencyP95 <= criteria.latencyP95Threshold
    );
  }

  private async rollback(flagKey: string): Promise<void> {
    const flag = this.engine['flags'].get(flagKey);
    if (flag) {
      flag.enabled = false;
      this.engine.setFlag(flag);
    }
  }

  private wait(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

A/B Testing with Variants

A/B testing requires variant assignment and consistent user experiences:

interface Variant {
  key: string;
  weight: number;
  value: any;
}

class ABTestManager {
  private engine: FeatureFlagEngine;

  getVariant(flagKey: string, context: FlagContext, variants: Variant[]): any {
    if (!this.engine.evaluate(flagKey, context)) {
      return null;
    }

    const totalWeight = variants.reduce((sum, v) => sum + v.weight, 0);
    const hash = this.hashString(`${context.userId}:${flagKey}`);
    const bucket = hash % totalWeight;

    let cumulative = 0;
    for (const variant of variants) {
      cumulative += variant.weight;
      if (bucket < cumulative) {
        return variant.value;
      }
    }

    return variants[0].value;
  }

  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;
    }
    return Math.abs(hash);
  }
}

Common Pitfalls

Inconsistent Flag Evaluation

Evaluating flags multiple times for the same user within a request can produce inconsistent results if flag state changes. Cache evaluation results per request:

class RequestScopedFlagCache {
  private cache: Map<string, boolean> = new Map();

  evaluate(engine: FeatureFlagEngine, flagKey: string, context: FlagContext): boolean {
    const cacheKey = `${flagKey}:${context.userId}`;

    if (!this.cache.has(cacheKey)) {
      this.cache.set(cacheKey, engine.evaluate(flagKey, context));
    }

    return this.cache.get(cacheKey)!;
  }
}

Flag Debt Accumulation

Temporary flags become permanent when teams forget to remove them. Implement flag lifecycle management:

interface FlagMetadata {
  createdAt: Date;
  createdBy: string;
  expiresAt?: Date;
  type: 'release' | 'experiment' | 'ops' | 'permission';
}

function auditExpiredFlags(flags: Map<string, Flag & { metadata: FlagMetadata }>): string[] {
  const now = new Date();
  const expired: string[] = [];

  flags.forEach((flag, key) => {
    if (flag.metadata.expiresAt && flag.metadata.expiresAt < now) {
      expired.push(key);
    }
  });

  return expired;
}

Performance Degradation

Synchronous flag evaluation can add latency. Use local caching with background updates:

class CachedFlagProvider {
  private cache: Map<string, Flag> = new Map();
  private lastUpdate: Date = new Date(0);
  private updateIntervalMs = 30000; // 30 seconds

  async getFlag(flagKey: string): Promise<Flag | undefined> {
    await this.refreshIfNeeded();
    return this.cache.get(flagKey);
  }

  private async refreshIfNeeded(): Promise<void> {
    const now = new Date();
    if (now.getTime() - this.lastUpdate.getTime() > this.updateIntervalMs) {
      await this.refresh();
    }
  }

  private async refresh(): Promise<void> {
    // Fetch from remote source
    const flags = await this.fetchFlags();
    flags.forEach(flag => this.cache.set(flag.key, flag));
    this.lastUpdate = new Date();
  }

  private async fetchFlags(): Promise<Flag[]> {
    // Implementation depends on your backend
    return [];
  }
}

Best Practices Checklist

  • [ ] Implement consistent hashing for percentage rollouts to ensure stable user experiences
  • [ ] Cache flag evaluations within request scope to prevent inconsistent behavior
  • [ ] Set expiration dates on all temporary flags and audit regularly
  • [ ] Monitor flag evaluation performance and implement local caching
  • [ ] Use structured logging to track flag evaluations for debugging
  • [ ] Implement circuit breakers to handle flag service outages gracefully
  • [ ] Version flag configurations to enable rollback of flag changes
  • [ ] Document flag purpose, owner, and expected removal date
  • [ ] Separate flag evaluation from business logic using dependency injection
  • [ ] Test both enabled and disabled states in automated tests
  • [ ] Implement gradual rollout stages with automated metric validation
  • [ ] Use semantic flag naming conventions (e.g., enable_new_checkout_flow)

LaunchDarkly Alternatives

While LaunchDarkly is popular, several alternatives offer different trade-offs:

Unleash (open-source): Self-hosted option with strong enterprise features. Provides SDKs for major languages and a robust admin UI.

Flagsmith (open-source): Cloud or self-hosted with generous free tier. Good for teams wanting control without vendor lock-in.

GrowthBook (open-source): Combines feature flags with experimentation platform. Strong analytics integration for A/B testing.

Split.io: Enterprise-focused with sophisticated targeting and analytics. Higher cost but comprehensive feature set.

ConfigCat: Simple, affordable SaaS option. Good for smaller teams needing basic feature flag functionality.

FAQ

How do I handle flag evaluation failures in production?

Implement fail-safe defaults. When flag evaluation fails, return a predetermined safe value (usually the old behavior). Use circuit breakers to prevent cascading failures and log all evaluation errors for investigation.

What's the difference between canary releases and progressive rollouts?

Canary releases deploy new code to a small subset of infrastructure (servers/pods) while progressive rollouts control feature visibility to users via flags. You can combine both: canary deploy code, then progressively roll out the feature flag.

How many rollout stages should I use?

Start with 1%, 5%, 25%, 50%, 100% for high-risk features. Low-risk features can use fewer stages like 10%, 50%, 100%. Adjust based on your user base size and risk tolerance.

Should I use feature flags for every change?

No. Use flags for user-facing features, risky changes, and experiments. Don't flag internal refactoring or bug fixes unless they carry significant risk. Too many flags create complexity.

How do I prevent flag state from causing data inconsistencies?

Design features to be idempotent and backward-compatible. Avoid flags that change data schemas. If unavoidable, use database migrations separate from flag state and ensure both states produce valid data.

What metrics should I monitor during rollouts?

Track error rates, latency percentiles (p50, p95, p99), conversion rates, and feature-specific KPIs. Set automated alerts for threshold violations and implement automatic rollback when critical metrics degrade.

How long should I keep a feature flag after full rollout?

Remove release flags within 1-2 weeks after reaching 100% rollout and confirming stability. Keep experiment flags until analysis completes. Permission flags and operational toggles can remain indefinitely but should be reviewed quarterly.