LaunchDarkly vs Unleash vs AWS AppConfig: Feature Flags at Scale
A production guide to feature flags in distributed systems, comparing LaunchDarkly, Unleash, and AWS AppConfig with examples for rollouts and A/B testing.
Feature flags let you deploy code to production and control feature visibility at runtime. Without that control, deploying features in a distributed system forces a choice between big-bang releases that risk outages and long-lived branches that accumulate merge conflicts. Every deployment becomes an all-or-nothing event tied to a business release schedule.
Three platforms cover most of the field, and the sensible default depends on where the system already runs. For a team already on AWS whose targeting stays close to “which environment, which tier, what percentage”, AWS AppConfig is the cheapest way in. It prices per request, puts no extra vendor in the request path, and builds validation and rollback into the deployment itself. LaunchDarkly earns its price when segment logic and native experimentation are the reason you want flags at all. Self-hosted Unleash is the trade for teams that need flag data to stay on their own infrastructure and can absorb running the service.
The Deployment Coordination Problem
The traditional solution, long-lived feature branches, brings its own problems. Branches diverge from main for weeks, merge conflicts multiply, and integration becomes increasingly painful.
With flags in place, you coordinate rollouts rather than deployments. A release becomes a configuration change applied to one user segment at a time, and rolled back the same way.
The target state is concrete: incomplete features sit in production behind disabled flags, get tested against real data, open progressively from 1% to 100% of users, and switch off instantly when something breaks.
Four Flag Types, Two Lifespans
Release Flags
Release flags control gradual rollout of new features. These flags have a clear lifecycle: created during development, enabled progressively during rollout, removed after reaching 100% adoption. Keeping these flags longer than necessary creates technical debt.
interface ReleaseFlag {
key: 'new-checkout-flow';
type: 'release';
defaultValue: false;
temporary: true;
expiresAt: '2025-03-01'; // Set expiration when creating
}
Experiment Flags
Experiment flags support A/B testing and multivariate experiments. Like release flags, these are temporary; they exist for the experiment duration and should be removed once the winning variation is implemented.
interface ExperimentFlag {
key: 'cta-button-color-experiment';
type: 'experiment';
variations: {
control: { color: 'blue' },
treatmentA: { color: 'green' },
treatmentB: { color: 'red' }
};
temporary: true;
expiresAt: '2025-02-15';
}
Ops and Permission Flags
The remaining two types are permanent. Ops flags act as circuit breakers and kill switches: the ability to disable a feature during an incident or under high load, without deploying new code.
interface OpsFlag {
key: 'enable-recommendation-engine';
type: 'ops';
defaultValue: true;
permanent: true;
purpose: 'Disable recommendation engine during high load';
}
Permission flags answer a different question: which customers get the feature. They key off user attributes, subscription tiers, or entitlements, and in SaaS applications they decide which features each customer segment sees.
interface PermissionFlag {
key: 'premium-analytics';
type: 'permission';
defaultValue: false;
permanent: true;
targetingRules: {
subscriptionTier: ['premium', 'enterprise']
};
}
Flag Lifecycle
Different flag types follow different lifecycle patterns:
Where the Three Differ
Choosing a feature flag platform means trading cost against capability and operational load. The split shows up in three places: where evaluation happens, how fast a change propagates, and who runs the service.
| Feature | LaunchDarkly | Unleash | AWS AppConfig |
|---|---|---|---|
| Hosting | SaaS only | Self-hosted or SaaS | AWS managed |
| Pricing | High (per seat + MAU) | Free (OSS) or paid SaaS | Pay per request |
| SDK Maturity | Excellent (15+ languages) | Good (15+ SDKs) | AWS SDK only |
| Targeting Rules | Very advanced | Good | Basic |
| A/B Testing | Native | Via integrations | Manual |
| Local Evaluation | Yes | Yes | Yes (with extension) |
| Real-time Updates | Yes | Yes | Polling (45s default) |
| Audit Logs | Comprehensive | Basic (paid tier) | CloudTrail |
LaunchDarkly
Targeting is where LaunchDarkly pulls ahead: segment rules go deeper than the other two, and experimentation ships with the platform. Flag updates propagate in real time over streaming connections, audit logs and change history are comprehensive, and the enterprise controls (RBAC, SSO, compliance) are there. The catch is the contract. Pricing combines per-seat and per-MAU charges on a plan structure that gets reworked often enough that any quoted figure ages badly, so price a concrete seat count and MAU volume from the current plan page before budgeting. Deployment is SaaS-only, which makes switching costs worth pricing at the same time.
Unleash
Unleash is the one you can run yourself. The project is Apache 2.0 licensed, SDK coverage across languages is good and the community is active; the UI is less mature than LaunchDarkly’s, targeting stays basic and the experimentation feature set is thinner. Self-hosting is what you are actually buying: flag data stays on your own infrastructure, and in return you operate a service and a database, on the order of $200 a month of ECS/EC2 and RDS plus the engineering time to keep them running.
AWS AppConfig
AWS AppConfig’s targeting stays basic, updates arrive via polling instead of push, and the SDK surface is limited to AWS: there’s no native A/B testing support and the dashboard is thin next to the alternatives. What it buys back is native integration with Lambda, ECS, and the rest of the AWS compute surface, FedRAMP certification with no external service dependency added to the request path, and pay-per-request pricing that stays cheap at scale, with validation and rollback built into the deployment flow itself. On 1M requests a month:
- Requests: 1M × $0.0000002 = $0.20/month
- Configurations: 10 × $0.50 = $5/month
- Total:
$5.20/month ($62/year)
Choosing Between Them
LaunchDarkly SDK Integration
Initialize the client once, as a singleton, and reuse it across the request lifecycle:
SDK Initialization
import { init, LDClient, LDFlagSet } from '@launchdarkly/node-server-sdk';
// Singleton client initialization
let ldClient: LDClient | null = null;
export async function initializeLaunchDarkly(): Promise<LDClient> {
if (ldClient) {
return ldClient;
}
ldClient = init(process.env.LAUNCHDARKLY_SDK_KEY!, {
// Performance optimization: reduce network calls
streamInitialReconnectDelay: 1000,
// Local evaluation for lower latency
sendEvents: true,
// Timeout configuration
timeout: 5,
});
await ldClient.waitForInitialization({ timeout: 5 });
console.log('LaunchDarkly initialized');
return ldClient;
}
Type-Safe Flag Evaluation
// User context for targeting
interface UserContext {
key: string;
email?: string;
country?: string;
customAttributes?: Record<string, any>;
}
// Type-safe flag evaluation
export async function evaluateFlag<T>(
flagKey: string,
user: UserContext,
defaultValue: T
): Promise<T> {
const client = await initializeLaunchDarkly();
const ldUser = {
key: user.key,
email: user.email,
country: user.country,
custom: user.customAttributes,
};
return client.variation(flagKey, ldUser, defaultValue);
}
Express Middleware Integration
import { Request, Response, NextFunction } from 'express';
export async function checkFeatureFlag(flagKey: string) {
return async (req: Request, res: Response, next: NextFunction) => {
const user = {
key: req.user?.id || 'anonymous',
email: req.user?.email,
country: req.headers['cloudfront-viewer-country'] as string,
};
const isEnabled = await evaluateFlag(flagKey, user, false);
if (!isEnabled) {
return res.status(403).json({
error: 'Feature not available'
});
}
next();
};
}
// Usage in route
app.post('/api/checkout',
checkFeatureFlag('new-checkout-flow'),
async (req, res) => {
// New checkout implementation
}
);
With local evaluation the SDK answers from an in-memory ruleset, so a flag check costs a map lookup instead of a network round trip. That overhead stays low even on a request path that evaluates several flags.
Unleash SDK Integration
Unleash polling runs on a 15-second interval here, with ready/error events for startup handling.
SDK Setup
import { initialize, isEnabled, getVariant } from 'unleash-client';
const unleash = initialize({
url: process.env.UNLEASH_URL!,
appName: 'order-service',
instanceId: process.env.HOSTNAME || 'local',
customHeaders: {
Authorization: process.env.UNLEASH_API_TOKEN!,
},
// Performance: local caching
refreshInterval: 15000, // 15 seconds
metricsInterval: 60000, // 1 minute
});
// Wait for SDK to be ready
unleash.on('ready', () => {
console.log('Unleash client ready');
});
unleash.on('error', (err) => {
console.error('Unleash error:', err);
});
Context-Based Evaluation
interface UnleashContext {
userId?: string;
sessionId?: string;
remoteAddress?: string;
properties?: Record<string, string>;
}
export function checkFlag(
flagName: string,
context: UnleashContext,
defaultValue = false
): boolean {
return isEnabled(flagName, context, defaultValue);
}
export function getFlagVariant(
flagName: string,
context: UnleashContext
): { name: string; payload?: any } {
return getVariant(flagName, context);
}
Gradual Rollout Example
export async function processOrder(orderId: string, userId: string) {
const context = {
userId,
sessionId: orderId,
properties: {
userTier: await getUserTier(userId),
region: await getUserRegion(userId),
},
};
// Simple on/off flag
const useNewPaymentGateway = checkFlag(
'new-payment-gateway',
context,
false
);
// Multivariate flag for A/B testing
const checkoutVariant = getFlagVariant('checkout-layout', context);
if (useNewPaymentGateway) {
return processWithNewGateway(orderId, checkoutVariant);
} else {
return processWithLegacyGateway(orderId);
}
}
AWS AppConfig with Lambda Extension
Each poll fetches only what changed, because the client tracks a configuration token between requests.
SDK Integration
import { AppConfigDataClient, StartConfigurationSessionCommand, GetLatestConfigurationCommand } from '@aws-sdk/client-appconfigdata';
interface FeatureFlagConfig {
flags: Record<string, {
enabled: boolean;
attributes?: Record<string, any>;
}>;
version: string;
}
let cachedConfig: FeatureFlagConfig | null = null;
let configToken: string | null = null;
export async function initializeAppConfig() {
const client = new AppConfigDataClient({ region: process.env.AWS_REGION });
const sessionCommand = new StartConfigurationSessionCommand({
ApplicationIdentifier: process.env.APPCONFIG_APPLICATION!,
EnvironmentIdentifier: process.env.APPCONFIG_ENVIRONMENT!,
ConfigurationProfileIdentifier: process.env.APPCONFIG_PROFILE!,
});
const response = await client.send(sessionCommand);
configToken = response.InitialConfigurationToken!;
}
export async function fetchFeatureFlags(): Promise<FeatureFlagConfig> {
if (!configToken) {
await initializeAppConfig();
}
const client = new AppConfigDataClient({ region: process.env.AWS_REGION });
const command = new GetLatestConfigurationCommand({
ConfigurationToken: configToken!,
});
const response = await client.send(command);
configToken = response.NextPollConfigurationToken!;
if (response.Configuration) {
const configString = new TextDecoder().decode(response.Configuration);
cachedConfig = JSON.parse(configString);
}
return cachedConfig!;
}
Lambda Handler with Flags
export const handler = async (event: any) => {
const config = await fetchFeatureFlags();
const userId = event.requestContext.authorizer.claims.sub;
// Simple flag check
const isNewFeatureEnabled = config.flags['new-dashboard']?.enabled || false;
// Attribute-based targeting
const userTier = await getUserTier(userId);
const premiumFeaturesFlag = config.flags['premium-features'];
const hasPremiumAccess =
premiumFeaturesFlag?.enabled &&
premiumFeaturesFlag?.attributes?.allowedTiers?.includes(userTier);
if (isNewFeatureEnabled && hasPremiumAccess) {
return {
statusCode: 200,
body: JSON.stringify({ dashboard: 'new', premium: true }),
};
}
return {
statusCode: 200,
body: JSON.stringify({ dashboard: 'legacy', premium: false }),
};
};
The Lambda extension caches configuration locally, which is what keeps cold start impact down. It polls AppConfig every 45 seconds and serves requests from that cache.
Note
Add the AWS AppConfig Lambda Extension layer to your function:
Layer ARN: arn:aws:lambda:us-east-1:027255383542:layer:AWS-AppConfig-Extension:279
The extension runs as a sidecar process and handles configuration fetching/caching automatically.
Targeting Rules and User Segmentation
Targeting Rule Implementation
interface TargetingRule {
attribute: string;
operator: 'equals' | 'contains' | 'greaterThan' | 'lessThan' | 'regex' | 'in';
values: any[];
}
interface Segment {
name: string;
rules: TargetingRule[];
rolloutPercentage?: number;
}
interface FeatureFlagDefinition {
key: string;
defaultValue: boolean;
segments: Segment[];
}
Flag Evaluator
class FeatureFlagEvaluator {
evaluateRule(rule: TargetingRule, context: Record<string, any>): boolean {
const attributeValue = context[rule.attribute];
if (attributeValue === undefined) {
return false;
}
switch (rule.operator) {
case 'equals':
return attributeValue === rule.values[0];
case 'in':
return rule.values.includes(attributeValue);
case 'contains':
return String(attributeValue).includes(String(rule.values[0]));
case 'greaterThan':
return Number(attributeValue) > Number(rule.values[0]);
case 'lessThan':
return Number(attributeValue) < Number(rule.values[0]);
case 'regex':
const pattern = new RegExp(rule.values[0]);
return pattern.test(String(attributeValue));
default:
return false;
}
}
evaluateSegment(segment: Segment, context: Record<string, any>): boolean {
// All rules in segment must match (AND logic)
const rulesMatch = segment.rules.every(rule =>
this.evaluateRule(rule, context)
);
if (!rulesMatch) {
return false;
}
// Apply percentage rollout if specified
if (segment.rolloutPercentage !== undefined) {
const hash = this.hashUserId(context.userId);
const bucket = hash % 100;
return bucket < segment.rolloutPercentage;
}
return true;
}
evaluateFlag(
flag: FeatureFlagDefinition,
context: Record<string, any>
): boolean {
// Check segments in order, return first match
for (const segment of flag.segments) {
if (this.evaluateSegment(segment, context)) {
return true;
}
}
return flag.defaultValue;
}
// Consistent hashing for percentage rollouts
private hashUserId(userId: string): number {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
const char = userId.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
}
Progressive Rollout Configuration
const evaluator = new FeatureFlagEvaluator();
const premiumFeatureFlag: FeatureFlagDefinition = {
key: 'premium-analytics',
defaultValue: false,
segments: [
{
name: 'Internal employees',
rules: [
{ attribute: 'email', operator: 'contains', values: ['@company.com'] }
],
},
{
name: 'Premium tier users',
rules: [
{ attribute: 'subscriptionTier', operator: 'in', values: ['premium', 'enterprise'] }
],
},
{
name: 'Beta users gradual rollout',
rules: [
{ attribute: 'betaOptIn', operator: 'equals', values: [true] }
],
rolloutPercentage: 20, // 20% of beta users
},
],
};
const userContext = {
userId: 'user-123',
email: '[email protected]',
subscriptionTier: 'premium',
betaOptIn: true,
};
const isEnabled = evaluator.evaluateFlag(premiumFeatureFlag, userContext);
Consistent hashing on the user ID is what keeps the same user on the same side of the split. Without it, feature state flips between enabled and disabled across requests.
A/B Testing Integration
The flag platform picks the variation and the analytics platform records the exposure, so both sides need the same user key.
Analytics Integration
import { init, LDClient } from '@launchdarkly/node-server-sdk';
import * as Amplitude from '@amplitude/node';
interface ExperimentContext {
userId: string;
userProperties: Record<string, any>;
eventProperties?: Record<string, any>;
}
class FeatureFlagAnalytics {
private ldClient: LDClient;
private amplitudeClient: Amplitude.Types.NodeClient;
constructor(ldKey: string, amplitudeKey: string) {
this.ldClient = init(ldKey);
this.amplitudeClient = Amplitude.init(amplitudeKey);
}
async evaluateExperiment(
experimentKey: string,
context: ExperimentContext,
defaultVariation: string
): Promise<string> {
const ldContext = {
key: context.userId,
custom: context.userProperties,
};
// Get variation from LaunchDarkly
const variation = await this.ldClient.variation(
experimentKey,
ldContext,
defaultVariation
);
// Track experiment exposure in Amplitude
await this.amplitudeClient.logEvent({
event_type: 'Experiment Viewed',
user_id: context.userId,
event_properties: {
experiment_name: experimentKey,
variation_name: variation,
...context.eventProperties,
},
user_properties: context.userProperties,
});
return variation;
}
async trackConversion(
experimentKey: string,
context: ExperimentContext,
conversionMetric: string,
value?: number
) {
await this.amplitudeClient.logEvent({
event_type: conversionMetric,
user_id: context.userId,
event_properties: {
experiment_name: experimentKey,
value,
...context.eventProperties,
},
user_properties: context.userProperties,
});
}
}
Experiment Implementation
const analytics = new FeatureFlagAnalytics(
process.env.LAUNCHDARKLY_KEY!,
process.env.AMPLITUDE_KEY!
);
export async function renderCheckoutButton(userId: string) {
const context = {
userId,
userProperties: {
accountAge: await getAccountAge(userId),
previousPurchases: await getPurchaseCount(userId),
},
};
// Get button color variation (control, green, red)
const buttonColor = await analytics.evaluateExperiment(
'checkout-button-color',
context,
'control' // Default blue button
);
return {
color: buttonColor === 'control' ? 'blue' : buttonColor,
experimentKey: 'checkout-button-color',
};
}
export async function handleCheckoutClick(userId: string, experimentKey: string) {
const context = {
userId,
userProperties: {},
eventProperties: {
page: 'checkout',
},
};
await analytics.trackConversion(
experimentKey,
context,
'Checkout Button Clicked'
);
}
export async function handlePurchaseComplete(
userId: string,
experimentKey: string,
amount: number
) {
const context = {
userId,
userProperties: {},
eventProperties: {
purchaseAmount: amount,
},
};
await analytics.trackConversion(
experimentKey,
context,
'Purchase Completed',
amount
);
}
Warning
A/B testing requires proper sample size calculation and statistical significance testing. Don’t declare a winner after 100 users; wait for p-value < 0.05 and sufficient sample size. Consider using tools like Evan Miller’s A/B test calculator to determine required sample size before starting experiments.
Kill Switches and Circuit Breakers
Operational flags enable quick response to incidents without deploying new code.
Circuit Breaker Implementation
import { EventEmitter } from 'events';
interface CircuitBreakerConfig {
flagKey: string;
errorThreshold: number; // Percentage of errors before opening
timeWindow: number; // Time window in ms
checkInterval: number; // How often to check flag state
}
enum CircuitState {
CLOSED = 'CLOSED', // Normal operation
OPEN = 'OPEN', // Circuit breaker triggered
HALF_OPEN = 'HALF_OPEN', // Testing if service recovered
}
class FeatureFlagCircuitBreaker extends EventEmitter {
private state: CircuitState = CircuitState.CLOSED;
private errors: number[] = [];
private requests: number[] = [];
private flagEnabled: boolean = true;
constructor(
private config: CircuitBreakerConfig,
private flagClient: any
) {
super();
this.startFlagMonitoring();
}
private startFlagMonitoring() {
setInterval(async () => {
// Check if flag was manually disabled (kill switch)
this.flagEnabled = await this.flagClient.variation(
this.config.flagKey,
{ key: 'system' },
true
);
if (!this.flagEnabled && this.state !== CircuitState.OPEN) {
this.openCircuit('Manual kill switch activated');
} else if (this.flagEnabled && this.state === CircuitState.OPEN) {
this.halfOpenCircuit();
}
}, this.config.checkInterval);
}
async executeWithCircuitBreaker<T>(
operation: () => Promise<T>,
fallback: () => T
): Promise<T> {
// If circuit is open or flag disabled, use fallback
if (this.state === CircuitState.OPEN || !this.flagEnabled) {
return fallback();
}
const now = Date.now();
this.requests.push(now);
try {
const result = await operation();
// Success in HALF_OPEN state closes circuit
if (this.state === CircuitState.HALF_OPEN) {
this.closeCircuit();
}
return result;
} catch (error) {
this.errors.push(now);
this.checkErrorThreshold();
throw error;
} finally {
this.cleanupOldMetrics(now);
}
}
private checkErrorThreshold() {
const now = Date.now();
const recentRequests = this.requests.filter(
t => now - t < this.config.timeWindow
);
const recentErrors = this.errors.filter(
t => now - t < this.config.timeWindow
);
if (recentRequests.length === 0) return;
const errorRate = (recentErrors.length / recentRequests.length) * 100;
if (errorRate >= this.config.errorThreshold) {
this.openCircuit(`Error rate ${errorRate.toFixed(2)}% exceeded threshold`);
}
}
private openCircuit(reason: string) {
this.state = CircuitState.OPEN;
this.emit('circuit-opened', { reason, flagKey: this.config.flagKey });
console.error(`Circuit breaker OPEN: ${reason}`);
}
private halfOpenCircuit() {
this.state = CircuitState.HALF_OPEN;
this.emit('circuit-half-open', { flagKey: this.config.flagKey });
console.log('Circuit breaker HALF_OPEN: testing recovery');
}
private closeCircuit() {
this.state = CircuitState.CLOSED;
this.errors = [];
this.emit('circuit-closed', { flagKey: this.config.flagKey });
console.log('Circuit breaker CLOSED: service recovered');
}
private cleanupOldMetrics(now: number) {
this.requests = this.requests.filter(
t => now - t < this.config.timeWindow
);
this.errors = this.errors.filter(
t => now - t < this.config.timeWindow
);
}
getState(): CircuitState {
return this.state;
}
}
Production Usage
const recommendationEngineBreaker = new FeatureFlagCircuitBreaker(
{
flagKey: 'enable-recommendation-engine',
errorThreshold: 50, // 50% error rate
timeWindow: 60000, // 1 minute
checkInterval: 5000, // Check flag every 5 seconds
},
ldClient
);
// Monitor circuit breaker events
recommendationEngineBreaker.on('circuit-opened', ({ reason }) => {
console.error('ALERT: Recommendation engine circuit breaker opened:', reason);
sendPagerDutyAlert('Recommendation engine disabled', reason);
});
export async function getRecommendations(userId: string) {
return recommendationEngineBreaker.executeWithCircuitBreaker(
// Primary operation: call recommendation engine
async () => {
const response = await fetch(`https://api.recommendations.com/users/${userId}`);
if (!response.ok) throw new Error('Recommendation API failed');
return response.json();
},
// Fallback: return popular items
() => {
return getPopularItems(); // Simple fallback
}
);
}
Flag Lifecycle Management
Flags that nobody removes turn into technical debt. Without active lifecycle management, the flag count keeps growing.
Lifecycle Tracking
interface FlagMetadata {
key: string;
type: 'release' | 'experiment' | 'ops' | 'permission';
createdAt: Date;
createdBy: string;
expiresAt?: Date;
status: 'active' | 'inactive' | 'launched' | 'deprecated';
evaluationCount: number;
lastEvaluated?: Date;
}
class FlagLifecycleManager {
private metadata: Map<string, FlagMetadata> = new Map();
private readonly INACTIVE_THRESHOLD_DAYS = 30;
private readonly STALE_FLAG_THRESHOLD_DAYS = 90;
constructor(private flagClient: any) {
this.startLifecycleMonitoring();
}
async evaluateFlag(
flagKey: string,
context: any,
defaultValue: any
): Promise<any> {
const value = await this.flagClient.variation(flagKey, context, defaultValue);
// Update metadata
const metadata = this.metadata.get(flagKey);
if (metadata) {
metadata.evaluationCount++;
metadata.lastEvaluated = new Date();
}
return value;
}
registerFlag(metadata: Omit<FlagMetadata, 'evaluationCount' | 'lastEvaluated'>) {
this.metadata.set(metadata.key, {
...metadata,
evaluationCount: 0,
});
}
findStaleFlags(): FlagMetadata[] {
const now = new Date();
const staleFlags: FlagMetadata[] = [];
this.metadata.forEach(flag => {
// Skip permanent flags (ops, permission)
if (flag.type === 'ops' || flag.type === 'permission') {
return;
}
// Check if expired
if (flag.expiresAt && now > flag.expiresAt) {
staleFlags.push(flag);
return;
}
// Check if inactive (no evaluations in 30 days)
if (flag.lastEvaluated) {
const daysSinceEvaluation =
(now.getTime() - flag.lastEvaluated.getTime()) / (1000 * 60 * 60 * 24);
if (daysSinceEvaluation > this.INACTIVE_THRESHOLD_DAYS) {
flag.status = 'inactive';
staleFlags.push(flag);
}
}
// Check if flag is old and never evaluated
const flagAge =
(now.getTime() - flag.createdAt.getTime()) / (1000 * 60 * 60 * 24);
if (flagAge > this.STALE_FLAG_THRESHOLD_DAYS && flag.evaluationCount === 0) {
staleFlags.push(flag);
}
});
return staleFlags;
}
async generateCleanupReport(): Promise<string> {
const staleFlags = this.findStaleFlags();
const report: string[] = [
'# Feature Flag Cleanup Report',
`Generated: ${new Date().toISOString()}`,
'',
'## Flags Ready for Removal',
'',
];
for (const flag of staleFlags) {
report.push(`### ${flag.key}`);
report.push(`- Type: ${flag.type}`);
report.push(`- Created: ${flag.createdAt.toISOString()}`);
report.push(`- Status: ${flag.status}`);
report.push(`- Evaluations: ${flag.evaluationCount}`);
report.push(`- Last evaluated: ${flag.lastEvaluated?.toISOString() || 'Never'}`);
if (flag.expiresAt) {
report.push(`- Expired: ${flag.expiresAt.toISOString()}`);
}
report.push('');
}
return report.join('\n');
}
private startLifecycleMonitoring() {
// Weekly cleanup check
setInterval(async () => {
const report = await this.generateCleanupReport();
console.log(report);
// In production: send to Slack, create Jira ticket, etc.
}, 7 * 24 * 60 * 60 * 1000); // Weekly
}
}
Flag Removal Process
Removing a launched flag runs in a fixed order:
- Identify flags that have reached 100% rollout (launched state)
- Verify flag always returns the same value
- Create pull request to remove flag code
- Deploy and monitor for issues
- Archive flag in platform
- Update documentation
Trunk-Based Development Integration
Feature flags enable trunk-based development by allowing incomplete features in the main branch.
Feature Toggle Pattern
export class FeatureToggle {
constructor(private flagClient: any) {}
async withFeature<T>(
flagKey: string,
context: any,
newImplementation: () => Promise<T>,
legacyImplementation: () => Promise<T>
): Promise<T> {
const isEnabled = await this.flagClient.variation(
flagKey,
context,
false // Default: disabled
);
if (isEnabled) {
try {
return await newImplementation();
} catch (error) {
console.error(`Feature ${flagKey} failed, falling back:`, error);
// Automatic fallback on error
return await legacyImplementation();
}
}
return await legacyImplementation();
}
}
Progressive Implementation
const toggle = new FeatureToggle(ldClient);
export async function processPayment(orderId: string, userId: string) {
const context = { key: userId };
return toggle.withFeature(
'new-payment-processor',
context,
// New implementation (under development)
async () => {
// Incomplete feature can be merged to main
// because it's behind flag (disabled by default)
return newPaymentProcessor.process(orderId);
},
// Legacy implementation (production)
async () => {
return legacyPaymentProcessor.process(orderId);
}
);
}
Testing Strategies
Testing feature-flagged code requires testing both enabled and disabled states.
Mock Flag Client
class MockFlagClient {
private flags: Map<string, any> = new Map();
setFlag(key: string, value: any) {
this.flags.set(key, value);
}
async variation(key: string, context: any, defaultValue: any): Promise<any> {
return this.flags.get(key) ?? defaultValue;
}
reset() {
this.flags.clear();
}
}
Test Both States
describe('Payment Processing', () => {
let mockFlags: MockFlagClient;
let paymentService: PaymentService;
beforeEach(() => {
mockFlags = new MockFlagClient();
paymentService = new PaymentService(mockFlags);
});
describe('with new payment processor ENABLED', () => {
beforeEach(() => {
mockFlags.setFlag('new-payment-processor', true);
});
it('should use new payment processor', async () => {
const result = await paymentService.processPayment('order-123', 'user-456');
expect(result.processor).toBe('new');
});
it('should handle new processor errors gracefully', async () => {
mockNewProcessor.process = jest.fn().mockRejectedValue(new Error('API Error'));
// Should fallback to legacy
const result = await paymentService.processPayment('order-123', 'user-456');
expect(result.processor).toBe('legacy');
});
});
describe('with new payment processor DISABLED', () => {
beforeEach(() => {
mockFlags.setFlag('new-payment-processor', false);
});
it('should use legacy payment processor', async () => {
const result = await paymentService.processPayment('order-123', 'user-456');
expect(result.processor).toBe('legacy');
});
it('should not call new processor', async () => {
const newProcessorSpy = jest.spyOn(mockNewProcessor, 'process');
await paymentService.processPayment('order-123', 'user-456');
expect(newProcessorSpy).not.toHaveBeenCalled();
});
});
});
Warning
Don’t test every combination of feature flags. With 10 flags, that’s 1,024 test cases. Instead:
- Test critical features with flags both ON and OFF
- Use risk-based testing (test risky features more thoroughly)
- Mock flag client for predictable behavior
- Integration tests use dedicated test environment flags
When to Override the Default
AppConfig holds as the default while the flags stay operational: kill switches, ops toggles, and release flags evaluated inside AWS compute, where 45-second polling and attribute-level targeting are enough.
Two things break that default. When segment logic changes weekly, or the flags exist so experiments can be read out with statistical rigor, LaunchDarkly’s targeting and experimentation are worth the seats. When flag data has to stay on your own infrastructure, self-hosted Unleash is the trade: a service and a database to run, in exchange for control. Whichever one you land on, give release and experiment flags an expiry date at creation, and let every flag default to the stable path so an unreachable flag service degrades quietly.
References
- Feature Toggles (aka Feature Flags) - Martin Fowler - Comprehensive patterns for release toggles, experiment toggles, ops toggles, and permission toggles
- OpenFeature: Introduction - CNCF standard for vendor-agnostic feature flag APIs
- LaunchDarkly: Feature Flag Best Practices - Production guides for flag lifecycle management
- OpenFeature: Five Minutes to Feature Flags - Quick-start tutorial for standardized feature flagging
- Martin Fowler: Feature Flag bliki - Concise definition and categorization of feature flags
Related posts
A practical guide to consumer-driven contract testing with Pact in TypeScript microservices, catching breaking API changes before deployment.
Build a testing strategy for AWS Lambda, API Gateway, DynamoDB, and Step Functions with practical patterns for fast feedback and reliability.
Git branching strategies mapped to team size, product type, and release cadence. GitHub Flow is the default; here is when another model earns its overhead.
How high-performing teams shrink the lead time from code-complete to live in production, without trading away security or code quality. A guide for tech leads.
Production deploys need a real approval gate: use GitHub Environments with native protection rules and scoped secrets, not workflow if: hacks or marketplace actions.