Skip to content

Feature Toggle Patterns for Safe Releases

How to implement feature toggles that enable trunk-based development, gradual rollouts and instant rollbacks — types, testing and cleanup that avoids debt.

5 min read
Feature toggle evaluation flow showing user context, toggle rules, and feature activation

Feature toggles decouple deployment from release. You can merge code to main, deploy it to production, and control who sees the new feature through configuration — not code changes. This enables trunk-based development, gradual rollouts, A/B testing, and instant rollbacks without redeployment.

But toggles have a cost. Every toggle adds a branch to your code, doubles the number of states to test, and becomes technical debt if not cleaned up. The pattern is powerful only when combined with discipline around toggle lifecycle management.

Types of Feature Toggles

Not all toggles are the same. The type determines who controls it, how long it lives, and how it should be tested.

tstypescript
// Toggle categories with distinct lifecycles
interface ToggleDefinition {
  name: string;
  type: 'release' | 'experiment' | 'ops' | 'permission';
  owner: string;
  createdAt: string;
  expectedRemovalDate: string;
  description: string;
}
 
const toggleRegistry: ToggleDefinition[] = [
  {
    name: 'new-checkout-flow',
    type: 'release',
    owner: 'checkout-team',
    createdAt: '2022-07-01',
    expectedRemovalDate: '2022-08-01',
    description: 'New checkout UI with express payment options',
    // Release toggles: short-lived, removed after full rollout
  },
  {
    name: 'search-algorithm-v2',
    type: 'experiment',
    owner: 'search-team',
    createdAt: '2022-07-10',
    expectedRemovalDate: '2022-08-10',
    description: 'Test new ranking algorithm against baseline',
    // Experiment toggles: short-lived, removed after metric analysis
  },
  {
    name: 'maintenance-mode',
    type: 'ops',
    owner: 'platform-team',
    createdAt: '2022-01-15',
    expectedRemovalDate: 'permanent',
    description: 'Disable writes during maintenance windows',
    // Ops toggles: long-lived, controlled by operations
  },
  {
    name: 'premium-features',
    type: 'permission',
    owner: 'product-team',
    createdAt: '2022-03-01',
    expectedRemovalDate: 'permanent',
    description: 'Gate premium features behind subscription tier',
    // Permission toggles: long-lived, part of the business model
  },
];

Implementing a Toggle Service

A toggle service evaluates whether a feature is enabled for a given context (user, environment, percentage).

tstypescript
interface ToggleContext {
  userId: string;
  userTier?: 'free' | 'pro' | 'enterprise';
  environment: 'development' | 'staging' | 'production';
  region?: string;
}
 
interface ToggleRule {
  enabled: boolean;
  conditions?: {
    environments?: string[];
    userTiers?: string[];
    userIds?: string[];           // Specific users (for internal testing)
    percentage?: number;           // Gradual rollout percentage
  };
}
 
class FeatureToggleService {
  private toggles: Map<string, ToggleRule>;
 
  constructor(toggleConfig: Record<string, ToggleRule>) {
    this.toggles = new Map(Object.entries(toggleConfig));
  }
 
  isEnabled(toggleName: string, context: ToggleContext): boolean {
    const rule = this.toggles.get(toggleName);
 
    // Unknown toggle = disabled (fail safe)
    if (!rule) return false;
 
    // Global kill switch
    if (!rule.enabled) return false;
 
    // No conditions = enabled for everyone
    if (!rule.conditions) return true;
 
    const { conditions } = rule;
 
    // Environment check
    if (
      conditions.environments &&
      !conditions.environments.includes(context.environment)
    ) {
      return false;
    }
 
    // Specific user allowlist (internal testers)
    if (conditions.userIds?.includes(context.userId)) {
      return true;
    }
 
    // User tier check
    if (
      conditions.userTiers &&
      context.userTier &&
      !conditions.userTiers.includes(context.userTier)
    ) {
      return false;
    }
 
    // Percentage rollout (deterministic based on user ID)
    if (conditions.percentage !== undefined) {
      const hash = this.hashUserId(context.userId, toggleName);
      return hash < conditions.percentage;
    }
 
    return true;
  }
 
  private hashUserId(userId: string, toggleName: string): number {
    // Deterministic hash: same user always gets same result
    let hash = 0;
    const input = `${toggleName}:${userId}`;
    for (let i = 0; i < input.length; i++) {
      hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
    }
    return Math.abs(hash) % 100;
  }
}

Using Toggles in Application Code

Keep toggle evaluation at the boundary. Do not scatter toggle checks deep inside business logic.

tstypescript
// ❌ Toggle checks scattered throughout the codebase
class CheckoutService {
  async processOrder(order: Order) {
    if (toggles.isEnabled('new-checkout-flow', ctx)) {
      // 50 lines of new flow
    } else {
      // 50 lines of old flow
    }
 
    if (toggles.isEnabled('new-checkout-flow', ctx)) {
      await this.sendNewConfirmationEmail(order);
    } else {
      await this.sendOldConfirmationEmail(order);
    }
 
    // Toggle checked 5 more times in this file...
    // Good luck testing all combinations
  }
}
 
// ✅ Toggle evaluated once at the boundary, strategy pattern inside
class CheckoutService {
  async processOrder(
    order: Order,
    checkoutFlow: CheckoutFlow  // Injected based on toggle
  ) {
    return checkoutFlow.process(order);
  }
}
 
// At the route/controller level — the boundary
app.post('/api/checkout', async (req, res) => {
  const ctx = getToggleContext(req);
  const flow = toggles.isEnabled('new-checkout-flow', ctx)
    ? new NewCheckoutFlow(deps)
    : new LegacyCheckoutFlow(deps);
 
  const result = await checkoutService.processOrder(req.body, flow);
  res.json(result);
});

Testing with Feature Toggles

Every toggle doubles the number of code paths. Testing strategy must account for both states — toggle on and toggle off.

tstypescript
describe('CheckoutService', () => {
  // Test both toggle states explicitly
  describe('with new checkout flow enabled', () => {
    const flow = new NewCheckoutFlow(mockDeps);
 
    it('processes order with express payment', async () => {
      const order = createTestOrder();
      const result = await checkoutService.processOrder(order, flow);
      expect(result.paymentMethod).toBe('express');
    });
 
    it('sends new confirmation email', async () => {
      const order = createTestOrder();
      await checkoutService.processOrder(order, flow);
      expect(mockEmail.sent).toContainEqual(
        expect.objectContaining({ template: 'new-confirmation' })
      );
    });
  });
 
  describe('with legacy checkout flow', () => {
    const flow = new LegacyCheckoutFlow(mockDeps);
 
    it('processes order with standard payment', async () => {
      const order = createTestOrder();
      const result = await checkoutService.processOrder(order, flow);
      expect(result.paymentMethod).toBe('standard');
    });
  });
});
 
// Integration test: verify toggle service itself
describe('FeatureToggleService', () => {
  it('enables feature for percentage rollout deterministically', () => {
    const service = new FeatureToggleService({
      'new-feature': {
        enabled: true,
        conditions: { percentage: 50 },
      },
    });
 
    // Same user always gets the same result
    const result1 = service.isEnabled('new-feature', {
      userId: 'user-123',
      environment: 'production',
    });
    const result2 = service.isEnabled('new-feature', {
      userId: 'user-123',
      environment: 'production',
    });
    expect(result1).toBe(result2);
  });
 
  it('returns false for unknown toggles', () => {
    const service = new FeatureToggleService({});
    const result = service.isEnabled('nonexistent', {
      userId: 'user-123',
      environment: 'production',
    });
    expect(result).toBe(false);
  });
});

Gradual Rollout Strategy

Roll out new features gradually — start with internal users, expand to a small percentage, and increase as confidence grows.

tstypescript
// Rollout progression for a new feature
const rolloutPlan = [
  {
    stage: 'internal',
    config: {
      enabled: true,
      conditions: {
        userIds: ['dev-1', 'dev-2', 'pm-1'],  // Specific team members
      },
    },
    duration: '3 days',
    criteria: 'No errors in logs, positive team feedback',
  },
  {
    stage: 'canary',
    config: {
      enabled: true,
      conditions: { percentage: 5 },
    },
    duration: '1 week',
    criteria: 'Error rate < 0.1%, latency p99 within 10% of baseline',
  },
  {
    stage: 'partial',
    config: {
      enabled: true,
      conditions: { percentage: 25 },
    },
    duration: '1 week',
    criteria: 'Same as canary + conversion rate stable',
  },
  {
    stage: 'majority',
    config: {
      enabled: true,
      conditions: { percentage: 75 },
    },
    duration: '3 days',
    criteria: 'All metrics stable at scale',
  },
  {
    stage: 'full',
    config: { enabled: true },
    duration: 'permanent until cleanup',
    criteria: 'Remove old code path, delete toggle',
  },
];
shbash
# ❌ No rollout strategy
# Deploy new feature to 100% of users on Friday at 5 PM
# Hope nothing breaks over the weekend
 
# ✅ Gradual rollout with monitoring
# Week 1: Internal team (3 users) — catch obvious bugs
# Week 2: 5% of users — validate at low scale
# Week 3: 25% of users — watch for edge cases
# Week 4: 75% of users — performance at scale
# Week 5: 100% + remove toggle and old code

Toggle Cleanup

Stale toggles are technical debt. Every toggle that outlives its purpose adds complexity, confuses new developers, and increases test surface area. Track toggle lifecycle and enforce cleanup.

tstypescript
// scripts/check-stale-toggles.ts
import { toggleRegistry } from '../config/toggles';
 
function findStaleToggles(): ToggleDefinition[] {
  const now = new Date();
  const stale: ToggleDefinition[] = [];
 
  for (const toggle of toggleRegistry) {
    if (toggle.expectedRemovalDate === 'permanent') continue;
 
    const removalDate = new Date(toggle.expectedRemovalDate);
    if (now > removalDate) {
      stale.push(toggle);
    }
  }
 
  return stale;
}
 
const stale = findStaleToggles();
if (stale.length > 0) {
  console.warn(`⚠️  ${stale.length} stale toggle(s) found:`);
  for (const t of stale) {
    console.warn(`  - ${t.name} (owner: ${t.owner}, expected removal: ${t.expectedRemovalDate})`);
  }
  // In CI: this could fail the build or create a ticket
  process.exit(1);
}

Key Takeaways

  1. Classify toggles by type — release, experiment, ops, and permission toggles have different lifecycles and owners
  2. Evaluate toggles at the boundary — check the toggle once in the controller and inject the appropriate strategy, not scattered throughout business logic
  3. Use deterministic hashing for percentage rollouts — the same user must always see the same variant; random evaluation creates inconsistent experiences
  4. Test both toggle states explicitly — every toggle doubles code paths; untested paths will break when you flip the toggle
  5. Roll out gradually — internal → 5% → 25% → 75% → 100%, with clear criteria for advancing each stage
  6. Enforce toggle cleanup — track expected removal dates and fail CI when toggles outlive their intended lifespan
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX