Node.js Feature Flags in Production: LaunchDarkly, Unleash, and Custom Toggles
The safest way to deploy new code is to ship it to production without turning it on. Feature flags — also called feature toggles — make this possible. They decouple code deployment from feature activation, letting you release to a subset of users, run A/B tests, and kill features instantly without a redeploy.
This guide covers three approaches for production Node.js: the LaunchDarkly SDK (managed, enterprise-grade), Unleash (self-hosted, open-source), and a zero-dependency custom toggle system you can ship in an afternoon. All production-ready patterns.
Why Feature Flags Change How You Deploy
Without feature flags, the typical production incident goes like this: you deploy, something breaks, you redeploy the previous version. That rollback takes 5–15 minutes, during which users see the broken behavior.
With feature flags: you disable the flag. The rollback is instantaneous. No redeploy, no pipeline wait, no Slack panic. The code is still there — it's just off.
Beyond rollbacks, flags enable:
| Use Case | How |
|---|---|
| Canary releases | Enable for 1% of users, watch metrics, expand |
| A/B testing | Route 50% of users to each variant, measure conversion |
| Beta programs | Enable for specific user IDs or account tiers |
| Kill switches | Disable a broken integration without code changes |
| Operational toggles | Disable expensive features under load |
| Trunk-based development | Ship incomplete features behind a flag, never branch |
Option 1: LaunchDarkly (Managed, Enterprise-Grade)
LaunchDarkly is the most feature-rich managed flag service. It handles targeting rules, percentage rollouts, multivariate flags, and real-time updates without polling.
npm install @launchdarkly/node-server-sdk
Initialization
// flags/launchdarkly.js
const { init } = require('@launchdarkly/node-server-sdk');
let ldClient;
async function getClient() {
if (ldClient) return ldClient;
ldClient = init(process.env.LAUNCHDARKLY_SDK_KEY);
await ldClient.waitForInitialization({ timeout: 10 });
console.log('LaunchDarkly SDK initialized');
return ldClient;
}
// Graceful shutdown
process.on('SIGTERM', async () => {
if (ldClient) await ldClient.close();
});
module.exports = { getClient };
Evaluating Flags
// flags/flags.js
const { getClient } = require('./launchdarkly');
// Standard boolean flag
async function isEnabled(flagKey, userId, userAttributes = {}) {
const client = await getClient();
const context = {
kind: 'user',
key: userId,
...userAttributes, // email, plan, country, etc.
};
return client.variation(flagKey, context, false); // false = default if flag not found
}
// Multivariate flag (string variant)
async function getVariant(flagKey, userId, userAttributes = {}) {
const client = await getClient();
const context = { kind: 'user', key: userId, ...userAttributes };
return client.variation(flagKey, context, 'control'); // 'control' = default variant
}
// Flag with detailed reason (useful for logging)
async function isEnabledWithReason(flagKey, userId) {
const client = await getClient();
const context = { kind: 'user', key: userId };
return client.variationDetail(flagKey, context, false);
// Returns: { value: true/false, variationIndex: 0, reason: { kind: 'RULE_MATCH', ... } }
}
module.exports = { isEnabled, getVariant, isEnabledWithReason };
Usage in Routes
// routes/checkout.js
const { isEnabled } = require('../flags/flags');
app.post('/checkout', authenticate, async (req, res) => {
const userId = req.user.id;
// New checkout flow behind a flag
const useNewCheckout = await isEnabled('new-checkout-flow', userId, {
plan: req.user.plan,
country: req.user.country,
});
if (useNewCheckout) {
return newCheckoutHandler(req, res);
}
return legacyCheckoutHandler(req, res);
});
Middleware Pattern for Request-Level Flags
// middleware/feature-flags.js
const { getClient } = require('../flags/launchdarkly');
function withFlags(...flagKeys) {
return async (req, res, next) => {
const client = await getClient();
const userId = req.user?.id || 'anonymous';
const context = {
kind: 'user',
key: userId,
plan: req.user?.plan,
email: req.user?.email,
};
const flags = {};
await Promise.all(
flagKeys.map(async key => {
flags[key] = await client.variation(key, context, false);
})
);
req.flags = flags;
next();
};
}
// Usage
app.get('/dashboard',
authenticate,
withFlags('new-sidebar', 'analytics-v2', 'ai-recommendations'),
(req, res) => {
res.render('dashboard', {
flags: req.flags,
// Template checks req.flags['new-sidebar'] etc.
});
}
);
Option 2: Unleash (Self-Hosted, Open-Source)
Unleash gives you full control over your flag data. No SaaS dependency, no data leaving your infrastructure. The trade-off: you run the server.
# Run Unleash server via Docker
docker run -d \
--name unleash \
-p 4242:4242 \
-e DATABASE_URL=postgres://user:pass@localhost/unleash \
unleashorg/unleash-server:latest
npm install unleash-client
Client Setup
// flags/unleash.js
const { initialize } = require('unleash-client');
const unleash = initialize({
url: process.env.UNLEASH_URL || 'http://localhost:4242/api',
appName: 'my-node-app',
instanceId: process.env.HOSTNAME || 'local',
customHeaders: { Authorization: process.env.UNLEASH_TOKEN },
// Bootstrap flags for zero-latency startup (fallback if server unreachable)
bootstrap: {
data: [
{ name: 'new-checkout-flow', enabled: false, strategies: [{ name: 'default' }] },
{ name: 'ai-recommendations', enabled: false, strategies: [{ name: 'default' }] },
],
},
// Polling interval
refreshInterval: 15000, // 15 seconds
metricsInterval: 60000, // report metrics every 60 seconds
});
unleash.on('error', err => console.error('Unleash error:', err));
unleash.on('synchronized', () => console.log('Unleash flags synchronized'));
module.exports = { unleash };
Evaluating with User Context
// flags/flags-unleash.js
const { unleash } = require('./unleash');
function isEnabled(flagName, userId, sessionId, properties = {}) {
return unleash.isEnabled(flagName, {
userId,
sessionId, // Used for gradual rollouts (consistent hashing)
properties, // Custom attributes for targeting rules
});
}
// Percentage rollout: 10% of users
// In Unleash UI: add gradualRollout strategy with percentage=10, stickiness=userId
module.exports = { isEnabled };
Variants (A/B Testing)
// flags/variants.js
const { unleash } = require('./unleash');
function getVariant(flagName, userId) {
return unleash.getVariant(flagName, { userId });
// Returns: { name: 'blue' | 'green' | 'disabled', enabled: true, payload: { type: 'string', value: '...' } }
}
// Usage
app.get('/pricing', authenticate, (req, res) => {
const variant = getVariant('pricing-page-test', req.user.id);
if (variant.enabled && variant.name === 'annual-first') {
return res.render('pricing-annual-first');
}
res.render('pricing-monthly-first');
});
Option 3: Zero-Dependency Custom Toggle System
Sometimes you don't need a managed service or self-hosted infra. Here's a production-ready flag system with PostgreSQL persistence, in-memory caching, and a REST API.
# Only requires your existing PostgreSQL connection
-- Migration: create feature_flags table
CREATE TABLE feature_flags (
key VARCHAR(100) PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 100, -- 0-100
allowed_user_ids TEXT[], -- null = all users
description TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by VARCHAR(100)
);
-- Seed initial flags
INSERT INTO feature_flags (key, enabled, description) VALUES
('new-checkout-flow', false, 'Redesigned checkout with address autocomplete'),
('ai-recommendations', false, 'AI-powered product recommendations'),
('maintenance-mode', false, 'Show maintenance banner globally');
// flags/custom-flags.js
const { createHash } = require('crypto');
class FeatureFlagService {
constructor(pool) {
this.pool = pool;
this.cache = new Map();
this.cacheTTL = 30 * 1000; // 30 seconds
// Refresh all flags on startup
this.refresh();
setInterval(() => this.refresh(), this.cacheTTL);
}
async refresh() {
const { rows } = await this.pool.query('SELECT * FROM feature_flags');
rows.forEach(row => {
this.cache.set(row.key, {
enabled: row.enabled,
rolloutPercentage: row.rollout_percentage,
allowedUserIds: row.allowed_user_ids,
fetchedAt: Date.now(),
});
});
}
isEnabled(flagKey, userId = null) {
const flag = this.cache.get(flagKey);
if (!flag) return false; // Unknown flag = off
if (!flag.enabled) return false; // Globally disabled
// User allowlist check
if (flag.allowedUserIds && flag.allowedUserIds.length > 0) {
return flag.allowedUserIds.includes(String(userId));
}
// Percentage rollout (consistent per user)
if (flag.rolloutPercentage < 100 && userId) {
const hash = createHash('sha256')
.update(`${flagKey}:${userId}`)
.digest('hex');
const bucket = parseInt(hash.substring(0, 8), 16) % 100;
return bucket < flag.rolloutPercentage;
}
return true;
}
async enable(flagKey, updatedBy = 'system') {
await this.pool.query(
'UPDATE feature_flags SET enabled = true, updated_at = NOW(), updated_by = $2 WHERE key = $1',
[flagKey, updatedBy]
);
await this.refresh();
}
async disable(flagKey, updatedBy = 'system') {
await this.pool.query(
'UPDATE feature_flags SET enabled = false, updated_at = NOW(), updated_by = $2 WHERE key = $1',
[flagKey, updatedBy]
);
await this.refresh();
}
async setRollout(flagKey, percentage, updatedBy = 'system') {
await this.pool.query(
'UPDATE feature_flags SET rollout_percentage = $2, updated_at = NOW(), updated_by = $3 WHERE key = $1',
[flagKey, percentage, updatedBy]
);
await this.refresh();
}
listAll() {
return Object.fromEntries(this.cache.entries());
}
}
module.exports = { FeatureFlagService };
Admin REST API for Your Flags
// routes/admin-flags.js
const express = require('express');
const router = express.Router();
// Protect with admin middleware
router.use(requireAdminAuth);
router.get('/', (req, res) => {
res.json(req.flags.listAll());
});
router.post('/:key/enable', async (req, res) => {
await req.flags.enable(req.params.key, req.user.email);
res.json({ status: 'enabled', key: req.params.key });
});
router.post('/:key/disable', async (req, res) => {
await req.flags.disable(req.params.key, req.user.email);
res.json({ status: 'disabled', key: req.params.key });
});
router.post('/:key/rollout', async (req, res) => {
const { percentage } = req.body;
if (typeof percentage !== 'number' || percentage < 0 || percentage > 100) {
return res.status(400).json({ error: 'percentage must be 0-100' });
}
await req.flags.setRollout(req.params.key, percentage, req.user.email);
res.json({ status: 'updated', key: req.params.key, percentage });
});
module.exports = router;
The Kill Switch Pattern
The most important feature flag is the one you use when things go wrong. A kill switch disables a broken feature immediately — no code change, no deploy.
// middleware/kill-switches.js
const KILL_SWITCHES = [
'maintenance-mode',
'disable-ai-features',
'disable-email-sending',
'read-only-mode',
];
async function checkKillSwitches(req, res, next) {
const flags = req.app.get('flags');
if (flags.isEnabled('maintenance-mode')) {
return res.status(503).json({
error: 'Service temporarily unavailable for maintenance',
retryAfter: 300,
});
}
if (flags.isEnabled('read-only-mode') && req.method !== 'GET') {
return res.status(503).json({
error: 'System is in read-only mode. Please try again shortly.',
});
}
next();
}
module.exports = { checkKillSwitches };
Runbook: Flag-Based Incident Response
When an incident fires:
# 1. Immediately disable the broken feature (< 30 seconds)
curl -X POST https://api.yourapp.com/admin/flags/new-checkout-flow/disable \
-H "Authorization: Bearer $ADMIN_TOKEN"
# 2. Verify the flag is off
curl https://api.yourapp.com/admin/flags/new-checkout-flow
# 3. Watch error rate drop (should be immediate)
# 4. Debug the root cause with the feature off
# 5. Fix, test, then re-enable at 1% rollout
curl -X POST https://api.yourapp.com/admin/flags/new-checkout-flow/rollout \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"percentage": 1}'
Percentage Rollout Strategy
The safest way to launch anything:
// Canonical canary rollout schedule
const ROLLOUT_STAGES = [
{ day: 0, percentage: 1, note: 'Internal users only' },
{ day: 1, percentage: 5, note: 'Watch error rates + p99 latency' },
{ day: 2, percentage: 10, note: 'Watch conversion metrics' },
{ day: 3, percentage: 25, note: 'Sanity check revenue impact' },
{ day: 5, percentage: 50, note: 'Final check before full rollout' },
{ day: 7, percentage: 100, note: 'Full rollout — remove flag from code' },
];
// Alerting thresholds: kill the rollout if any of these breach
const ABORT_CONDITIONS = {
errorRateIncrease: 0.5, // +0.5% absolute error rate
p99LatencyIncrease: 200, // +200ms p99 latency
conversionDrop: 2, // -2% conversion rate
};
Testing with Feature Flags
Feature flags complicate testing. Here's the right pattern:
// tests/checkout.test.js
const { FeatureFlagService } = require('../flags/custom-flags');
// Mock the flag service for tests
jest.mock('../flags/custom-flags', () => ({
FeatureFlagService: jest.fn().mockImplementation(() => ({
isEnabled: jest.fn().mockReturnValue(false),
listAll: jest.fn().mockReturnValue({}),
})),
}));
describe('Checkout', () => {
let flagService;
beforeEach(() => {
flagService = new FeatureFlagService();
});
it('uses legacy checkout when flag is off', async () => {
flagService.isEnabled.mockReturnValue(false);
// ... test legacy path
});
it('uses new checkout when flag is on', async () => {
flagService.isEnabled.mockReturnValue(true);
// ... test new path
});
it('handles flag service failure gracefully', async () => {
flagService.isEnabled.mockImplementation(() => {
throw new Error('Flag service unavailable');
});
// Should fall back to default behavior, not crash
});
});
Key rule: Always test both flag-on and flag-off paths. Remove the test (and the flag) once fully rolled out.
Production Checklist
- [ ] Default value is the safe/off state (never
trueas default) - [ ] Flag evaluation is wrapped in try/catch — a broken flag service should not crash your app
- [ ] Flags are evaluated at request time, not application startup (users should see instant changes)
- [ ] Every flag has an owner and an expected removal date
- [ ] Kill switches bypass normal authentication/middleware stack
- [ ] Percentage rollout uses consistent hashing (same user always gets same variant)
- [ ] Flag changes are logged with who changed them and when
- [ ] Stale flags (> 30 days post-full-rollout) are removed from code — flag debt is real
Choosing the Right Option
| Criteria | LaunchDarkly | Unleash | Custom |
|---|---|---|---|
| Setup time | < 30 min | 2-4 hours | 2-3 hours |
| Targeting rules | Advanced (geo, plan, device) | Good | Manual |
| Real-time updates | Yes (streaming) | Yes (polling) | Polling |
| Cost | $$$ (free tier: 1 seat) | Free (self-hosted) | Free |
| Data residency | Cloud (SOC 2) | Your infra | Your infra |
| Analytics | Built-in | Basic | Manual |
| Best for | Enterprise, compliance-sensitive | Mid-size teams | Simple needs / cost-constrained |
If you're a solo dev or small team: start with the custom solution. When you have a team and need targeting rules: Unleash. When you need enterprise audit trails, SSO, and analytics: LaunchDarkly.
Summary
Feature flags are the highest-leverage change you can make to your deployment process. They decouple risk from code delivery, enabling:
- Instant rollbacks without a redeploy
- Gradual rollouts that catch issues at 1% before they hit 100%
- A/B testing without a separate infrastructure
- Operational kill switches that work faster than any CI/CD pipeline
The implementation is straightforward. The discipline — testing both paths, removing stale flags, logging changes — is what separates teams that benefit from feature flags and teams that create flag debt.
Ship the feature. Turn it on when you're ready. Turn it off when you're not.
Part of the Node.js Production Series — battle-tested patterns for running Node.js at scale.
Top comments (0)