Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Feature Flags and Progressive Rollouts

For .NET engineers who know: Azure App Configuration feature flags, LaunchDarkly SDK for .NET, IFeatureManager from Microsoft.FeatureManagement, and basic A/B testing concepts You’ll learn: How to implement feature flags across the JS/TS stack — from simple environment variable flags to full targeting rules — and when to graduate from one level to the next Time: 10-15 min read


The .NET Way (What You Already Know)

In the Microsoft ecosystem, feature flags usually live in Azure App Configuration. You reference Microsoft.FeatureManagement in your project, configure the IFeatureManager service, and toggle flags in the Azure Portal without a code deployment. Flags can have simple on/off state or targeting rules (percentage rollout, user group, A/B split). The [FeatureGate("MyFeature")] action filter keeps flag-protected routes clean.

// Startup.cs
builder.Services.AddAzureAppConfiguration();
builder.Services.AddFeatureManagement();

// Controller usage
[FeatureGate("BetaCheckout")]
[HttpGet("checkout-v2")]
public async Task<IActionResult> CheckoutV2() { ... }

// Service usage
public class OrderService
{
    private readonly IFeatureManager _features;

    public async Task<decimal> CalculateTax(Order order)
    {
        if (await _features.IsEnabledAsync("NewTaxEngine"))
            return await _newTaxEngine.Calculate(order);
        return await _legacyTaxEngine.Calculate(order);
    }
}

LaunchDarkly provides a more sophisticated version of this with real-time flag delivery, user targeting, A/B testing analytics, and kill switches — but the usage pattern is similar.

The mental model: feature flags are a runtime configuration system that sits above your code and lets you control execution paths without deployments.


The JS/TS Way

The JS/TS ecosystem has no single standard equivalent to Microsoft.FeatureManagement. The good news: the concept translates directly, and there are options at every complexity level. The bad news: you have to choose and integrate the tool yourself.

Here is the progression, from simplest to most powerful.

Level 1: Environment Variable Flags

The starting point for most features. Zero dependencies, works immediately, and forces you to define what “on” and “off” means before adding targeting complexity.

// src/config/features.ts
export const features = {
  betaCheckout: process.env.FEATURE_BETA_CHECKOUT === 'true',
  newTaxEngine: process.env.FEATURE_NEW_TAX_ENGINE === 'true',
  aiSuggestions: process.env.FEATURE_AI_SUGGESTIONS === 'true',
} as const;

export type FeatureName = keyof typeof features;
// In a NestJS service
import { features } from '../config/features';

@Injectable()
export class OrderService {
  async calculateTax(order: Order): Promise<number> {
    if (features.newTaxEngine) {
      return this.newTaxEngine.calculate(order);
    }
    return this.legacyTaxEngine.calculate(order);
  }
}

In React or Vue, import the same config:

// src/components/Checkout.tsx
import { features } from '@/config/features';

export function Checkout() {
  return (
    <div>
      {features.betaCheckout ? <CheckoutV2 /> : <CheckoutV1 />}
    </div>
  );
}

Set flags in your hosting platform’s environment variables. On Render:

FEATURE_BETA_CHECKOUT=true
FEATURE_NEW_TAX_ENGINE=false

Changing a flag requires updating the environment variable and redeploying (or restarting the service). This is the key limitation of env var flags — there is no real-time toggle.

When env var flags are enough:

  • Feature is either fully on or fully off for all users
  • You are comfortable with a redeploy to change the flag state
  • You do not need per-user targeting
  • You do not need analytics on flag exposure

When to graduate to a flag service:

  • You need to enable a feature for specific users, groups, or a percentage of traffic
  • You need to toggle flags without a deployment
  • You need analytics on who saw which variant
  • You are running a formal A/B test with statistical significance requirements

Level 2: Database Flags

When you need per-tenant or per-user flag control without a third-party service, store flags in the database.

-- Migration: add feature_flags table
CREATE TABLE feature_flags (
  id          SERIAL PRIMARY KEY,
  name        TEXT NOT NULL UNIQUE,
  enabled     BOOLEAN NOT NULL DEFAULT false,
  description TEXT,
  updated_at  TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TABLE user_feature_flags (
  user_id     TEXT NOT NULL,
  flag_name   TEXT NOT NULL REFERENCES feature_flags(name),
  enabled     BOOLEAN NOT NULL,
  PRIMARY KEY (user_id, flag_name)
);
// src/feature-flags/feature-flags.service.ts
@Injectable()
export class FeatureFlagsService {
  constructor(private readonly prisma: PrismaClient) {}

  async isEnabled(flagName: string, userId?: string): Promise<boolean> {
    // User-specific override takes precedence
    if (userId) {
      const userFlag = await this.prisma.userFeatureFlags.findUnique({
        where: { userId_flagName: { userId, flagName } },
      });
      if (userFlag !== null) return userFlag.enabled;
    }

    // Fall back to global flag state
    const flag = await this.prisma.featureFlags.findUnique({
      where: { name: flagName },
    });
    return flag?.enabled ?? false;
  }
}

Cache flag lookups aggressively — you do not want a database query on every request:

import NodeCache from 'node-cache';

@Injectable()
export class FeatureFlagsService {
  private readonly cache = new NodeCache({ stdTTL: 30 }); // 30 second TTL

  async isEnabled(flagName: string, userId?: string): Promise<boolean> {
    const cacheKey = `${flagName}:${userId ?? 'global'}`;
    const cached = this.cache.get<boolean>(cacheKey);
    if (cached !== undefined) return cached;

    const result = await this.lookupFlag(flagName, userId);
    this.cache.set(cacheKey, result);
    return result;
  }
}

Level 3: Feature Flag Service (LaunchDarkly / Flagsmith / PostHog)

For user targeting, gradual rollouts, and A/B testing analytics, integrate a dedicated flag service. Our recommended progression:

  • Flagsmith — open source, self-hostable, generous free tier, clean API
  • PostHog — pairs flags with product analytics, good if you already use PostHog for events
  • LaunchDarkly — enterprise-grade, real-time delivery, expensive but battle-tested

Flagsmith integration in NestJS:

pnpm add flagsmith-nodejs
// src/feature-flags/flagsmith.service.ts
import Flagsmith from 'flagsmith-nodejs';

@Injectable()
export class FlagsmithService implements OnModuleInit {
  private client: Flagsmith;

  async onModuleInit() {
    this.client = new Flagsmith({
      environmentKey: process.env.FLAGSMITH_ENVIRONMENT_KEY!,
      enableAnalytics: true,
    });
    // Pre-fetch all flags for performance
    await this.client.getEnvironmentFlags();
  }

  async isEnabled(flagName: string, userId?: string): Promise<boolean> {
    if (userId) {
      const flags = await this.client.getIdentityFlags(userId, {
        traits: { userId },
      });
      return flags.isFeatureEnabled(flagName);
    }
    const flags = await this.client.getEnvironmentFlags();
    return flags.isFeatureEnabled(flagName);
  }

  async getVariant(flagName: string, userId: string): Promise<string | null> {
    const flags = await this.client.getIdentityFlags(userId);
    return flags.getFeatureValue(flagName) as string | null;
  }
}

Flagsmith integration in React:

pnpm add flagsmith react-flagsmith
// src/providers/FlagsmithProvider.tsx
import { FlagsmithProvider } from 'react-flagsmith';
import flagsmith from 'flagsmith';

export function AppFlagsmithProvider({ children, userId }: {
  children: React.ReactNode;
  userId?: string;
}) {
  return (
    <FlagsmithProvider
      flagsmith={flagsmith}
      options={{
        environmentID: import.meta.env.VITE_FLAGSMITH_ENVIRONMENT_KEY,
        identity: userId, // enables user-specific flags
        cacheFlags: true,
        enableAnalytics: true,
      }}
    >
      {children}
    </FlagsmithProvider>
  );
}
// In a component
import { useFlags } from 'react-flagsmith';

export function CheckoutPage() {
  const { beta_checkout } = useFlags(['beta_checkout']);

  return (
    <div>
      {beta_checkout.enabled ? <CheckoutV2 /> : <CheckoutV1 />}
    </div>
  );
}

Feature Flags in NestJS Guards

The cleanest way to protect entire routes behind a flag is a NestJS guard — the equivalent of [FeatureGate] in ASP.NET Core:

// src/guards/feature-flag.guard.ts
import { CanActivate, ExecutionContext, Injectable, SetMetadata } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { FeatureFlagsService } from '../feature-flags/feature-flags.service';

export const RequireFlag = (flagName: string) =>
  SetMetadata('featureFlag', flagName);

@Injectable()
export class FeatureFlagGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly flags: FeatureFlagsService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const flagName = this.reflector.get<string>('featureFlag', context.getHandler());
    if (!flagName) return true; // no flag requirement, allow through

    const request = context.switchToHttp().getRequest();
    const userId = request.user?.id;
    return this.flags.isEnabled(flagName, userId);
  }
}
// Usage on a controller method
@Get('checkout-v2')
@UseGuards(AuthGuard, FeatureFlagGuard)
@RequireFlag('beta_checkout')
async checkoutV2(@Req() req: Request) {
  // Only reachable if beta_checkout flag is enabled for this user
}

Progressive Rollout

Percentage-based rollouts let you expose a feature to N% of users without a third-party service:

// Deterministic percentage rollout: same user always gets same experience
function isInRollout(userId: string, flagName: string, percentage: number): boolean {
  if (percentage <= 0) return false;
  if (percentage >= 100) return true;

  // Hash userId + flagName to a stable number in [0, 100)
  const hash = cyrb53(`${flagName}:${userId}`) % 100;
  return hash < percentage;
}

// Simple non-cryptographic hash (good enough for rollouts, not for security)
function cyrb53(str: string): number {
  let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
  for (let i = 0; i < str.length; i++) {
    const ch = str.charCodeAt(i);
    h1 = Math.imul(h1 ^ ch, 2654435761);
    h2 = Math.imul(h2 ^ ch, 1597334677);
  }
  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
  return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}

Store the percentage in your database flags table and query it:

async isEnabled(flagName: string, userId?: string): Promise<boolean> {
  const flag = await this.prisma.featureFlags.findUnique({
    where: { name: flagName },
  });

  if (!flag?.enabled) return false;

  // If rolloutPercentage is set, check if this user is in the rollout
  if (flag.rolloutPercentage !== null && userId) {
    return isInRollout(userId, flagName, flag.rolloutPercentage);
  }

  return flag.enabled;
}

A/B Testing Basics

A proper A/B test requires:

  1. Assignment — each user is deterministically assigned to variant A or B
  2. Exposure logging — record which users saw which variant
  3. Outcome logging — record which users completed the goal action
  4. Analysis — calculate statistical significance of the difference
// Simple A/B assignment with exposure logging
type Variant = 'control' | 'treatment';

async function assignVariant(
  userId: string,
  experimentName: string,
): Promise<Variant> {
  const hash = cyrb53(`${experimentName}:${userId}`) % 100;
  const variant: Variant = hash < 50 ? 'control' : 'treatment';

  // Log exposure to analytics
  await this.analytics.track({
    event: 'experiment_exposure',
    userId,
    properties: {
      experimentName,
      variant,
    },
  });

  return variant;
}

For anything beyond simple assignment, use PostHog (which combines analytics and flags) or a dedicated experimentation platform. Do not build your own statistical analysis — the math for correct significance testing is subtle and easy to get wrong.


Key Differences

.NET/Azure ApproachJS/TS Approach
Microsoft.FeatureManagement + Azure App ConfigurationRoll your own (env vars, DB) or use Flagsmith/PostHog/LaunchDarkly
[FeatureGate("Flag")] attribute on action methodsNestJS FeatureFlagGuard + @RequireFlag() decorator
IFeatureManager.IsEnabledAsync()featureFlagsService.isEnabled() injected via DI
Real-time flag update without redeploy (Azure App Config)Env var flags require redeploy; DB flags update immediately; flag services are real-time
Feature filters (percentage, targeting groups)Implement manually or use a flag service
Integrated A/B testing (LaunchDarkly)PostHog or LaunchDarkly; basic assignment is simple, analysis requires tooling

Gotchas for .NET Engineers

Gotcha 1: Env var flags require a redeploy on Render. Azure App Configuration updates propagate to running instances without a restart (via polling or push). Render environment variable changes do not take effect until you redeploy or restart the service. If you need real-time flag toggles — to kill a bad feature immediately without a deployment pipeline — env var flags alone are insufficient. This is the primary reason to graduate to a database-backed or service-backed flag system.

Gotcha 2: Client-side and server-side flags must be kept in sync. In .NET, you typically have one source of truth for flag state (Azure App Config or LaunchDarkly server SDK). In a Next.js or Nuxt application, flag checks happen in three places: the server (during SSR), the API (during request handling), and the client (during hydration and user interaction). If these three read from different sources or have different evaluation timing, you get inconsistent behavior — a user sees the new feature rendered on the server but the old feature kicks in on the client. Use the same flag service on both sides, and pass initial flag state from server to client during SSR to avoid a flash of wrong content.

Gotcha 3: Feature flags are permanent technical debt without a cleanup policy. Every feature flag is a branch in your code. Flags that ship and never get removed accumulate over time until no one is confident about what combination of flags is valid in production. Establish a rule when you create each flag: what is the condition under which this flag is removed? Commonly: the flag is removed in the sprint after it reaches 100% rollout. Add a TODO(TICKET-123): remove flag after full rollout comment at every flag evaluation site, and track flag removal as part of the original feature ticket.

Gotcha 4: Percentage rollouts must be deterministic, not random. A common mistake is using Math.random() < 0.10 to implement a 10% rollout. This assigns a user to a random variant on each page load or request, so a user sees the new feature on one request and the old feature on the next. Use a hash of the user ID (as shown above) so each user is always in the same bucket. This matters for both user experience and statistical validity of any A/B test.

Gotcha 5: Flag state in tests must be explicit. In .NET, IFeatureManager is an interface you can mock. In your JS/TS codebase, if flags are read directly from process.env, tests that run in a fixed environment will always see the same flag state. Either inject a flag service (mockable) or set environment variables in test setup:

// In vitest.config.ts or test setup
process.env.FEATURE_BETA_CHECKOUT = 'false';

// Or inject a mock service
const mockFlags = { isEnabled: vi.fn().mockResolvedValue(true) };

Always test both flag states — do not let the flag default cause one path to go untested.


Hands-On Exercise

Implement a three-level feature flag system for a NestJS + React application.

Part 1: Environment variable flag

  1. Add FEATURE_NEW_DASHBOARD=true to your .env file.
  2. Create a features.ts config file that reads all FEATURE_* environment variables.
  3. Use the flag in a React component to conditionally render an old and new dashboard component.
  4. Verify the toggle works by changing the env var value.

Part 2: Database flag with caching

  1. Add a feature_flags table to your Prisma schema with name, enabled, and rollout_percentage columns.
  2. Create a FeatureFlagsService with an isEnabled(flagName, userId?) method.
  3. Add a 30-second in-process cache using node-cache.
  4. Create an admin endpoint to update flag state (protected by an admin guard).
  5. Test that changing the flag in the database takes effect within 30 seconds without a redeploy.

Part 3: Guard and decorator

  1. Implement the FeatureFlagGuard and @RequireFlag() decorator from the examples above.
  2. Apply @RequireFlag('new_dashboard_api') to a new API endpoint.
  3. Verify that the endpoint returns 403 when the flag is disabled and 200 when it is enabled.
  4. Write a test for both states by mocking FeatureFlagsService.

Part 4: Progressive rollout

  1. Add the cyrb53 hash function and isInRollout utility to your codebase.
  2. Update FeatureFlagsService.isEnabled to respect the rollout_percentage column.
  3. Set the rollout to 50% and write a test that generates 1,000 different user IDs and verifies that approximately 500 receive true.
  4. Verify the hash is deterministic: the same user ID always produces the same result.

Quick Reference

Decision Tree: Which Flag Approach?

Do you need to toggle without a redeploy?
├── No → Environment variable flag (simplest)
└── Yes
    ├── Do you need per-user/per-tenant targeting?
    │   ├── No → Database flag with cache
    │   └── Yes
    │       ├── Do you need A/B test analytics?
    │       │   ├── No → Database flag with rollout_percentage
    │       │   └── Yes → PostHog or LaunchDarkly
    │       └── Do you need real-time delivery (<1s toggle)?
    │           ├── No → Database flag (30s cache TTL)
    │           └── Yes → Flagsmith or LaunchDarkly

Flag Naming Conventions

# Environment variables: FEATURE_ prefix, SCREAMING_SNAKE_CASE
FEATURE_BETA_CHECKOUT=true
FEATURE_NEW_TAX_ENGINE=false
FEATURE_AI_SUGGESTIONS=true

# Database / service flag names: snake_case
beta_checkout
new_tax_engine
ai_suggestions

# TypeScript config object keys: camelCase
features.betaCheckout
features.newTaxEngine
features.aiSuggestions

NestJS Integration Pattern

// 1. Define the service interface
interface IFeatureFlagsService {
  isEnabled(flagName: string, userId?: string): Promise<boolean>;
}

// 2. Inject where needed
constructor(@Inject(FEATURE_FLAGS_SERVICE) private flags: IFeatureFlagsService) {}

// 3. Guard usage
@UseGuards(FeatureFlagGuard)
@RequireFlag('my_feature')
@Get('my-route')
async myRoute() { ... }

// 4. Service usage
const useNewFlow = await this.flags.isEnabled('new_checkout_flow', user.id);

Flag Cleanup Checklist

When a flag reaches 100% rollout and has been stable for one sprint:

  • Remove all isEnabled() calls for the flag
  • Delete the “off” code path entirely (the old behavior)
  • Remove the flag from feature_flags table (or set to deprecated)
  • Remove any A/B test tracking for this flag
  • Update any documentation that mentions the flag
  • Close the original feature flag ticket

Comparison: Options

OptionRedeploy to change?User targetingAnalyticsCost
Env varYesNoNoFree
DB flagNo (< cache TTL)Yes (custom)NoYour DB costs
FlagsmithNoYesBasicFree tier available
PostHog flagsNoYesFull product analyticsFree tier available
LaunchDarklyNoYesFull$$$

Further Reading