Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 079 / 100

Feature toggles: Design Patterns lessons from production

Decouple deployment from release.

Expona Admin8 min read

Feature toggles: Design Patterns lessons from production

Deploying code and releasing features shouldn't be the same operation. Feature toggles: design patterns lessons from production teach us how to decouple deployment from release, letting teams ship unfinished work safely and control feature visibility in real time. This pattern has become essential infrastructure in environments where we deploy multiple times daily but need surgical control over what customers actually see.

We've learned across dozens of production systems that the ability to toggle features without redeploying is transformative. It's not just a convenience—it's a risk management strategy that lets us handle failures gracefully, test under load, and maintain velocity without sacrificing stability.

Understanding the core pattern

Feature toggles are conditional code paths controlled by external configuration. Rather than branching in version control or deploying only complete features, we wrap new functionality behind a condition that checks a flag at runtime. This decoupling means we can deploy unfinished features to production while keeping them hidden from users.

The pattern solves a fundamental tension in agile development: we want to merge frequently and deploy often, but releasing half-built features creates confusion. Feature toggles eliminate this false choice.

typescript
// Without toggles: feature sits in a branch, undeployed
// With toggles: feature deploys hidden, releases when ready

class OrderService {
  async createOrder(userId: string, items: OrderItem[]) {
    const order = await this.db.orders.create({
      userId,
      items,
      total: this.calculateTotal(items),
    });

    if (featureFlags.isEnabled('new-discount-engine', userId)) {
      // New logic only runs if toggle is on
      const discount = await this.discountEngine.calculate(items);
      order.total = order.total - discount;
      await this.db.orders.update(order.id, { total: order.total });
    }

    return order;
  }
}

At its core, this is simple—a boolean check wrapped around a code path. But the pattern gets sophisticated when you consider scope (user-level vs system-wide), configuration (hardcoded, database, external service), and lifecycle (temporary, permanent, abandoned).

Release toggles for unfinished work

Release toggles are the most common type. They let us ship incomplete features to production while keeping them invisible to most users. A team builds a new checkout flow over several sprints. Rather than keeping that code in a feature branch where it risks becoming stale, they deploy it daily behind a release toggle, testing it internally or with a small beta group.

The key insight: this is not the same as dark launching. We're not gradually rolling out to all users. We're explicitly keeping the feature off and turning it on when ready. This removes deployment risk entirely—the new code is already in production, we've tested it there, and enabling it is just a flag change.

typescript
// Release toggle pattern
class CheckoutController {
  async getCheckoutPage(req: Request) {
    const user = req.user;
    
    // Determine which version to serve
    const useNewCheckout = await this.featureFlags.isEnabled(
      'new-checkout-flow',
      { userId: user.id, percentage: 10 } // 10% beta rollout
    );

    if (useNewCheckout) {
      return await this.newCheckoutService.render(user);
    }
    
    return await this.legacyCheckoutService.render(user);
  }
}

// Configuration in your feature flag service
interface FeatureToggleConfig {
  name: string;
  enabled: boolean;
  rolloutPercentage?: number;
  targetUsers?: string[];
  targetRegions?: string[];
}

We've seen teams use release toggles to ship components months ahead of go-live, running performance tests in production, gathering telemetry, and refining UX with safe fallbacks always available. When something breaks, turn the toggle off—no deployment, no incident severity escalation.

Ops toggles as kill switches

Ops toggles are emergency switches for production incidents. When a feature causes a cascading failure or bugs surface under load, an ops toggle lets you disable that code path instantly without a deployment. This is different from a release toggle—it's about resilience and fast recovery.

A payment processor integration starts timing out during a traffic spike. With an ops toggle, your on-call engineer flips a switch in the feature flag dashboard, and all payment requests immediately fall back to the legacy processor. No deployment pipeline, no waiting for CI/CD, no risk of introducing new bugs while fixing the old one.

typescript
// Ops toggle as kill switch
async function processPayment(order: Order): Promise {
  try {
    const useNewPaymentService = await featureFlags.isEnabled(
      'new-payment-service',
      { critical: true } // High-priority toggle
    );

    if (useNewPaymentService) {
      return await paymentService.charge(order);
    } else {
      return await legacyPaymentService.charge(order);
    }
  } catch (error) {
    logger.error('Payment failed', { orderId: order.id, error });
    // Fallback happens automatically if toggle is off
    throw error;
  }
}

// Configuration with short cache TTL for fast updates
interface OpsToggleConfig {
  cacheTtlSeconds: 5, // Refresh every 5 seconds for ops toggles
  criticalPath: true,  // Always log toggle decisions
}

The critical requirement here is speed. Ops toggles need to update within seconds, not minutes. We typically cache them with very short TTLs and connect to feature flag services that push updates immediately rather than requiring servers to poll. Your monitoring systems should also watch toggle flips—a toggle being disabled should create an alert.

Experiment toggles for A/B tests

Experiment toggles enable A/B testing and gradual rollouts. Rather than enabling a feature for all users at once, you route a percentage of traffic to the new code path while the rest sees the old behavior. You measure the impact, and if metrics improve, gradually increase the percentage until everyone uses the new version.

An e-commerce team wants to test a new search algorithm. They enable it for 5% of users, monitor search quality metrics, conversion rates, and latency. The algorithm actually hurts conversion, so they disable it. No data loss, no reverted code, no customer-visible failure—the experiment just stops.

typescript
// Experiment toggle with gradual rollout
class SearchService {
  async search(query: string, userId: string): Promise {
    // Consistent routing: same user always gets same experience
    const userBucket = this.hashUserToBucket(userId, 100);
    const rolloutPercentage = await featureFlags.getRolloutPercentage(
      'new-search-algorithm'
    );

    if (userBucket < rolloutPercentage) {
      return await this.newSearchAlgorithm(query);
    } else {
      return await this.legacySearchAlgorithm(query);
    }
  }

  private hashUserToBucket(userId: string, bucketCount: number): number {
    // Deterministic hash ensures same user always lands in same bucket
    const hash = crypto
      .createHash('md5')
      .update(`${userId}-search-experiment`)
      .digest('hex');
    return parseInt(hash.substring(0, 8), 16) % bucketCount;
  }
}

// Track experiment in your metrics platform
class MetricsCollector {
  trackSearch(userId: string, query: string, results: SearchResults) {
    const experimentGroup = featureFlags.getExperimentGroup('new-search-algorithm');
    
    metrics.record('search.results_count', results.length, {
      userId,
      experiment: experimentGroup,
    });
  }
}

Deterministic hashing is critical here. The same user must always see the same version of a feature throughout their session and across sessions. Otherwise they'll see inconsistent behavior and lose trust. Use the user ID (or a consistent identifier) as the hashing input, not random values.

Remove toggles once done

This is where many teams stumble. Feature toggles are temporary. Once a feature is stable and rolled out to everyone, remove the toggle from your code. Delete both the flag check and the old code path. Leaving toggles in place creates technical debt—each toggle is another branch in your logic that needs testing, another decision point that could fail.

Set a rule: if a toggle has been 100% enabled for two weeks, schedule its removal. Create a ticket, do the cleanup, and ship it in the next deploy. This discipline keeps your codebase clean.

typescript

// Before cleanup: toggle is at 100%
if (featureFlags.isEnabled('new-checkout-flow')) {
  return await newCheckoutService.render(user);
} else {
  return await legacyCheckoutService.render(user); // Dead code
}

// After cleanup: toggle removed entirely
return await newCheckoutService.render(user);

We also recommend documenting toggle lifecycle in your feature flag platform. Mark toggles with an expiration date or deprecation notice. Some teams use automated cleanup—toggles that reach 100% automatically get flagged for removal. The goal is treating toggles as temporary infrastructure, not permanent architecture.

Common mistakes

Storing flags in code instead of external configuration. If your toggles are hardcoded or buried in environment variables, you can't change them without deploying. The entire point is decoupling deployment from release. Use a proper feature flag service (LaunchDarkly, Unleash, or a custom service backed by a database you can update instantly).

Forgetting to remove toggles. Codebases become sprawling webs of dead code paths. Every toggle adds complexity. Remove toggles as soon as they've served their purpose. Set calendar reminders if needed.

Using toggles for long-term configuration. Toggles are for temporary feature control, not system-wide settings. If something needs to be configurable permanently, use a proper configuration service. The distinction matters because toggle infrastructure expects them to change frequently and disappear; permanent configuration should be managed differently.

Inadequate logging of toggle state. When something breaks, you need to know which toggles were on. Log toggle decisions in critical paths so you can correlate failures with flag state in your logs. Include the toggle name and which branch was taken.

Ignoring cache TTL and propagation latency. Feature flags aren't instant. There's propagation delay from when you flip a flag to when all servers know about it. For ops toggles, keep this to seconds. For experiment toggles, a minute is acceptable. Design your fallback logic knowing there's this window.

Key takeaways

  • Feature toggles decouple deployment from release, letting you ship code before it's ready for users.
  • Release toggles wrap unfinished features so teams can merge frequently without shipping incomplete work.
  • Ops toggles act as kill switches for production incidents, letting you disable broken features instantly.
  • Experiment toggles enable A/B testing and gradual rollouts with deterministic user bucketing.
  • Remove toggles once they've served their purpose to avoid technical debt and code complexity.
  • Use external configuration services, not hardcoded flags, so you can change toggles without deploying.
  • Log toggle decisions in critical paths for debugging and correlation with production issues.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles