Feature Flags in Production: Safe Deployments with LaunchDarkly and Unleash
Prologue: "We Deployed But Haven't Released Yet"
I didn't understand this the first time I heard it.
"We just deployed. So why can't users see it?"
"We haven't released it yet."
Deploying means getting code onto servers. Releasing means making a feature visible to users. Separating these two actions is the core of Feature Flags.
The old way: build a feature on a feature branch, merge to main when done, deploy. The problem: large features mean long-lived branches, merge conflicts accumulate, and rollback is painful once you've deployed.
Feature flags change the game. Merge unfinished code to main — it's hidden behind a flag, users can't see it. Keep deploying continuously. When ready, flip the flag on. If something breaks, flip it off. No code rollback needed.
1. Types of Feature Flags
Not all flags serve the same purpose. Martin Fowler's canonical classification has four types.
Release Flags
The most common case. Control when a finished feature becomes visible to users — either for gradual rollout or staged launch.
if (featureFlags.isEnabled('new-checkout-flow', user)) {
return <NewCheckoutFlow />;
} else {
return <OldCheckoutFlow />;
}
Lifespan: Short — remove after the feature stabilizes.
Experiment Flags
A/B testing. Split users into groups and measure which version performs better.
const variant = featureFlags.getVariant('homepage-cta-button', user);
// variant: 'control' | 'blue-cta' | 'red-cta'
const buttonColor = variant === 'blue-cta' ? 'blue' :
variant === 'red-cta' ? 'red' : 'gray';
Lifespan: Duration of the experiment.
Ops Flags
Control system behavior at runtime. Circuit breakers, degraded modes, cache toggling.
// Switch to cached mode when external payment API is unstable
if (featureFlags.isEnabled('payment-fallback-mode')) {
return getCachedPaymentMethods();
} else {
return await fetchPaymentMethods();
}
Lifespan: Potentially permanent.
Permission Flags
Gate access to features by user group. Premium features, beta programs, internal tooling.
if (featureFlags.isEnabled('advanced-analytics', user)) {
return <AdvancedDashboard />;
}
// Regular users never reach this code path
Lifespan: Until the business model changes.
2. Feature Branches vs Feature Flags
| Feature Branch | Feature Flag |
|---|
| Deploy readiness | Only after feature is complete | Anytime (hidden by flag) |
| Integration risk | Merge conflicts accumulate | None (always on main) |
| Rollback | Requires code revert | Just turn off the flag |
| Gradual rollout | Very hard | Built-in (1% → 10% → 100%) |
| A/B testing | Extremely hard | Native capability |
| Complexity | Code management | Flag lifecycle management |
Feature flags do have a downside: if (flag) branches accumulate in your code. Stale flags become "flag debt." More on that later.
Bottom line: If you have multiple features in parallel development and need gradual rollout, feature flags win.
3. LaunchDarkly vs Unleash
LaunchDarkly
SaaS-based enterprise solution. Zero infrastructure setup.
Pros:
- Fast start (< 5 minutes to first flag)
- Powerful targeting rules (user attributes, segments)
- Real-time updates via streaming (SSE)
- Audit logs, permissions, approval flows
- Integrations with Datadog, Slack, Jira
Cons:
- Paid (cost scales with seat count)
- Data lives on external servers
- Vendor lock-in
Unleash
Open-source, self-hosted.
Pros:
- Open source (free, self-hosted)
- Data stays in your infrastructure
- Active community
- Unleash Cloud option available
Cons:
- You manage the infrastructure (PostgreSQL + Node.js)
- Some enterprise features require paid plans
Comparison Table
| LaunchDarkly | Unleash OSS |
|---|
| Hosting | SaaS | Self-hosted |
| Startup cost | Medium (free tier available) | Low (free) |
| Ops burden | None | Real |
| Targeting sophistication | Very high | High |
| Real-time updates | Streaming | Polling (default 15s) |
| Audit logs | Comprehensive | Basic |
| SDK coverage | Broad | Broad |
Decision guide:
- Fast start, enterprise needs → LaunchDarkly
- Data sovereignty, cost-sensitive → Unleash
- Side project → Unleash self-hosted or LaunchDarkly free tier
4. LaunchDarkly SDK in Next.js App Router
Installation
npm install @launchdarkly/node-server-sdk
npm install launchdarkly-react-client-sdk
Server Components
// src/lib/launchdarkly.ts
import * as ld from '@launchdarkly/node-server-sdk';
let ldClient: ld.LDClient | null = null;
export async function getLDClient(): Promise<ld.LDClient> {
if (!ldClient) {
ldClient = ld.init(process.env.LAUNCHDARKLY_SDK_KEY!);
await ldClient.waitForInitialization({ timeout: 5 });
}
return ldClient;
}
export async function isFeatureEnabled(
flagKey: string,
user: { id: string; email?: string; plan?: string },
defaultValue = false
): Promise<boolean> {
const client = await getLDClient();
const context: ld.LDContext = {
kind: 'user',
key: user.id,
email: user.email,
custom: { plan: user.plan },
};
return client.variation(flagKey, context, defaultValue);
}
// app/[locale]/dashboard/page.tsx
import { isFeatureEnabled } from '@/lib/launchdarkly';
import { getCurrentUser } from '@/lib/auth';
export default async function DashboardPage() {
const user = await getCurrentUser();
const showNew = await isFeatureEnabled(
'new-dashboard-ui',
{ id: user.id, email: user.email, plan: user.plan }
);
return showNew ? <NewDashboard /> : <OldDashboard />;
}
Client Components
// src/components/providers/FeatureFlagProvider.tsx
'use client';
import { useFlags } from 'launchdarkly-react-client-sdk';
export function useFeatureFlag(flagKey: string, defaultValue = false): boolean {
const flags = useFlags();
return flags[flagKey] ?? defaultValue;
}
// src/components/EditorWrapper.tsx
'use client';
import { useFeatureFlag } from '@/components/providers/FeatureFlagProvider';
export function EditorWrapper() {
const showNewEditor = useFeatureFlag('new-markdown-editor');
return showNewEditor ? <NewMarkdownEditor /> : <LegacyEditor />;
}
5. Unleash SDK Integration
Start Unleash with Docker
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: unleash
POSTGRES_USER: unleash_user
POSTGRES_PASSWORD: password
unleash:
image: unleashorg/unleash-server:latest
ports:
- "4242:4242"
environment:
DATABASE_URL: postgres://unleash_user:password@postgres:5432/unleash
INIT_CLIENT_API_TOKENS: "default:development.unleash-insecure-api-token"
depends_on:
- postgres
Node.js Server SDK
// src/lib/unleash.ts
import { initialize, isEnabled, getVariant } from 'unleash-client';
let initialized = false;
export async function initUnleash() {
if (initialized) return;
initialize({
url: process.env.UNLEASH_URL!,
appName: 'codemapo',
customHeaders: { Authorization: process.env.UNLEASH_API_TOKEN! },
refreshInterval: 15,
});
initialized = true;
}
export function checkFlag(
flagName: string,
userId: string,
properties?: Record<string, string>
): boolean {
return isEnabled(flagName, { userId, properties });
}
React Client SDK
// src/components/providers/UnleashProvider.tsx
'use client';
import { FlagProvider, useFlag } from '@unleash/proxy-client-react';
export function UnleashProvider({
children,
userId,
}: {
children: React.ReactNode;
userId: string;
}) {
return (
<FlagProvider
config={{
url: process.env.NEXT_PUBLIC_UNLEASH_PROXY_URL!,
clientKey: process.env.NEXT_PUBLIC_UNLEASH_CLIENT_KEY!,
appName: 'codemapo',
refreshInterval: 15,
}}
context={{ userId }}
>
{children}
</FlagProvider>
);
}
// In a component
export function FeatureComponent() {
const showNewUI = useFlag('new-checkout-ui');
return showNewUI ? <NewCheckoutUI /> : <OldCheckoutUI />;
}
6. Gradual Rollout Strategies
Percentage Rollout
Day 1: 1% of users → monitor errors
Day 2: 5% → still stable?
Day 3: 25%
Day 5: 50%
Day 7: 100% → schedule flag removal
Kill Switch
For ops flags — immediately disable broken functionality:
async function processPayment(order: Order) {
if (!featureFlags.isEnabled('payment-service-enabled')) {
throw new ServiceUnavailableError('Payment service temporarily unavailable');
}
return await paymentProvider.charge(order);
}
One flag flip takes down the entire payment feature instantly — no deployment needed.
7. Managing Flag Debt
The Problem: Flags Accumulate
// Six months later...
if (featureFlags.isEnabled('new-ui-2023')) { // already 100% rolled out
if (featureFlags.isEnabled('checkout-v2')) { // should have been removed
if (featureFlags.isEnabled('beta-checkout-2024')) { // still experimenting
return <NewCheckout />;
}
return <CheckoutV2 />;
}
return <OldCheckout />;
}
return <LegacyUI />;
Flag Lifecycle Discipline
Document flags at creation:
/**
* @flag new-checkout-flow
* @type release
* @created 2026-01-15
* @expires 2026-02-15
* @owner team-payments
*/
Removal checklist after 100% rollout:
- [ ] Running at 100% with no issues for at least 2 weeks
- [ ] Error rates and latency metrics normal
- [ ] Remove `if (featureFlags.isEnabled(...))` branches from code
- [ ] Delete the old code path (the thing the flag was guarding)
- [ ] Archive the flag in LaunchDarkly/Unleash
- [ ] Update related tests
8. Testing with Feature Flags
Unit Tests: Mock the Flag
// __tests__/checkout.test.tsx
jest.mock('launchdarkly-react-client-sdk', () => ({
useFlags: () => ({ 'new-checkout-flow': true }),
}));
it('shows new checkout UI when flag is on', () => {
render(<CheckoutPage />);
expect(screen.getByTestId('new-checkout')).toBeInTheDocument();
});
E2E Tests: Override via Cookie
// playwright/tests/checkout.spec.ts
test('new checkout flow E2E', async ({ page }) => {
await page.context().addCookies([{
name: 'ld_override',
value: JSON.stringify({ 'new-checkout-flow': true }),
domain: 'localhost',
path: '/',
}]);
await page.goto('/checkout');
await expect(page.getByTestId('new-checkout')).toBeVisible();
});
Epilogue: From Deploy Fear to Deploy Confidence
Before feature flags, deployments were stressful. "What if something breaks?" No deploys on Friday afternoons. Rollback procedures prepared in advance.
After feature flags: deploy any time. If something breaks, turn off the flag. Roll out gradually while watching metrics. Hit 100% when confident.
Separating deployment from release. That's all it is. But that single separation completely changes a team's relationship with deploying.