| name | feature-flags |
| description | Feature flag patterns for safe feature rollout without deployment: boolean flags, percentage rollouts, user targeting, and flag lifecycle. Covers LaunchDarkly, Unleash, homegrown Redis-based, and trunk-based development. |
Feature Flags Skill
Deploy code without releasing features. Feature flags decouple deployment from release, enabling dark launches, gradual rollouts, A/B tests, and instant kill switches.
When to Activate
- Rolling out a feature to a subset of users first
- Enabling trunk-based development (no long-lived feature branches)
- Implementing A/B testing or experiments
- Needing an instant kill switch for a risky feature
- Testing a feature in production before public launch
- Managing feature access by plan tier or user segment
Flag Types
| Type | Use Case | Example |
|---|
| Boolean | On/Off for everyone | new-checkout-flow: true |
| Percentage | Gradual rollout | 10% → 50% → 100% of users |
| User-targeted | Beta users, internal team | user.id IN [123, 456] |
| Attribute-based | By plan, country, etc. | user.plan == 'pro' |
Option A: Managed Service (LaunchDarkly / Unleash)
Pros: Real-time updates, rich targeting, analytics, no infrastructure
Cons: Cost, vendor dependency
LaunchDarkly (TypeScript)
import { init } from '@launchdarkly/node-server-sdk';
const ldClient = init(process.env.LAUNCHDARKLY_SDK_KEY);
await ldClient.waitForInitialization({ timeout: 10 });
const showNewCheckout = await ldClient.variation(
'new-checkout-flow',
{ key: user.id, email: user.email, custom: { plan: user.plan } },
false,
);
if (showNewCheckout) {
return newCheckoutFlow();
}
return legacyCheckoutFlow();
Unleash (self-hosted, TypeScript)
import { initialize } from 'unleash-client';
const unleash = initialize({
url: process.env.UNLEASH_URL,
appName: 'order-service',
customHeaders: { Authorization: process.env.UNLEASH_TOKEN },
});
await new Promise(resolve => unleash.on('synchronized', resolve));
if (unleash.isEnabled('new-checkout-flow', { userId: user.id })) {
return newCheckoutFlow();
}
Option B: Homegrown Redis-Based (simple, no external service)
Best for small teams, internal flags, or when vendor cost is prohibitive.
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
export interface FlagConfig {
enabled: boolean;
percentage?: number;
allowList?: string[];
denyList?: string[];
}
export async function isEnabled(flagName: string, userId?: string): Promise<boolean> {
const raw = await redis.get(`flag:${flagName}`);
if (!raw) return false;
const config: FlagConfig = JSON.parse(raw);
if (!config.enabled) return false;
if (userId) {
if (config.?.(userId)) ;
(config.?.(userId)) ;
(config. !== ) {
hash = (userId + flagName);
(hash % ) < config.;
}
}
config.;
}
(): {
hash = ;
( i = ; i < input.; i++) {
hash = ((hash << ) + hash) + input.(i);
}
.(hash);
}
() {
redis.(, .(config));
}
enabled = (, req..);
Trunk-Based Development with Flags
Feature flags enable everyone to merge to main continuously, even for incomplete features:
@startuml
start
:Feature branch\n(short-lived, < 1 day);
:Add flag: new-feature OFF;
:Merge to main\n(flag is off — safe);
:Continue development\non main behind flag;
:Internal testing\n(flag on for team only);
:Beta rollout\n(flag on for 5% of users);
:Full rollout\n(flag on for 100%);
:Remove flag\nclean up code;
stop
@enduml
Rule: Every new feature that takes > 1 day gets a flag. No feature branches longer than a day.
Flag Lifecycle
Flags MUST be cleaned up. Stale flags are technical debt.
if (await isEnabled('new-checkout', userId)) {
}
Track flag age. Create a ticket when adding each flag for cleanup in 4-8 weeks.
Flag Naming Convention
<area>-<feature>-<variant?>
Examples:
checkout-new-flow
auth-passkeys
payments-stripe-v2
dashboard-dark-mode
api-cursor-pagination
Testing with Feature Flags
async function processOrder(order: Order, flags: FlagResolver) {
if (await flags.isEnabled('new-checkout', order.userId)) {
return newCheckout(order);
}
return legacyCheckout(order);
}
it('uses new checkout when flag enabled', async () => {
const flags = { isEnabled: async () => true };
await expect(processOrder(order, flags)).resolves.toEqual(newResult);
});
it('uses legacy checkout when flag disabled', async () => {
const flags = { isEnabled: async () => false };
await expect(processOrder(order, flags)).resolves.toEqual(legacyResult);
});
Checklist