Feature Flags and Progressive Rollouts
For .NET engineers who know: Azure App Configuration feature flags, LaunchDarkly SDK for .NET,
IFeatureManagerfrom 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:
- Assignment — each user is deterministically assigned to variant A or B
- Exposure logging — record which users saw which variant
- Outcome logging — record which users completed the goal action
- 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 Approach | JS/TS Approach |
|---|---|
Microsoft.FeatureManagement + Azure App Configuration | Roll your own (env vars, DB) or use Flagsmith/PostHog/LaunchDarkly |
[FeatureGate("Flag")] attribute on action methods | NestJS 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
- Add
FEATURE_NEW_DASHBOARD=trueto your.envfile. - Create a
features.tsconfig file that reads allFEATURE_*environment variables. - Use the flag in a React component to conditionally render an old and new dashboard component.
- Verify the toggle works by changing the env var value.
Part 2: Database flag with caching
- Add a
feature_flagstable to your Prisma schema withname,enabled, androllout_percentagecolumns. - Create a
FeatureFlagsServicewith anisEnabled(flagName, userId?)method. - Add a 30-second in-process cache using
node-cache. - Create an admin endpoint to update flag state (protected by an admin guard).
- Test that changing the flag in the database takes effect within 30 seconds without a redeploy.
Part 3: Guard and decorator
- Implement the
FeatureFlagGuardand@RequireFlag()decorator from the examples above. - Apply
@RequireFlag('new_dashboard_api')to a new API endpoint. - Verify that the endpoint returns 403 when the flag is disabled and 200 when it is enabled.
- Write a test for both states by mocking
FeatureFlagsService.
Part 4: Progressive rollout
- Add the
cyrb53hash function andisInRolloututility to your codebase. - Update
FeatureFlagsService.isEnabledto respect therollout_percentagecolumn. - Set the rollout to 50% and write a test that generates 1,000 different user IDs and verifies that approximately 500 receive
true. - 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_flagstable (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
| Option | Redeploy to change? | User targeting | Analytics | Cost |
|---|---|---|---|---|
| Env var | Yes | No | No | Free |
| DB flag | No (< cache TTL) | Yes (custom) | No | Your DB costs |
| Flagsmith | No | Yes | Basic | Free tier available |
| PostHog flags | No | Yes | Full product analytics | Free tier available |
| LaunchDarkly | No | Yes | Full | $$$ |
Further Reading
- Flagsmith Documentation — open source flag service, good starting point
- PostHog Feature Flags — pairs flags with product analytics
- LaunchDarkly Node.js SDK — enterprise option with .NET parity
- Martin Fowler — Feature Toggles — the definitive taxonomy of flag types and their trade-offs
- Microsoft.FeatureManagement docs — reference for the .NET equivalent you already know