It was 2:47 AM when the alerts started. A seemingly straightforward database migration had triggered a cascading failure across three downstream services, and our payment processing pipeline was dropping roughly 12% of transactions. The on-call engineer didn't need to wake anyone, locate a rollback script, or wait for a CI pipeline to churn through another deploy. She opened the LaunchDarkly dashboard, toggled one kill switch, and the system reverted to the stable path within seconds. The migration was still there, still deployed — just no longer live.
That moment crystallized something I'd been learning across two and a half decades of building software: separating deployment from release isn't a nice-to-have. It's the difference between a system you trust and one you fear touching on a Friday afternoon.
This article captures what I've learned using feature flags in production — the patterns that held up under pressure, the mistakes I've watched teams repeat (and made myself), and the practical steps you can take whether you're evaluating LaunchDarkly or already deep into your feature flag journey. I'm publishing this here first because the developer community gives the most honest feedback, and I'd rather refine these ideas with you before they land on LeadDev and DZone.
The Patterns That Actually Matter
When you first start with feature flags, everything looks like a toggle. The key consideration here is understanding that not all flags serve the same purpose, and conflating them creates the very fragility you're trying to avoid.
Release Flags
These gate unfinished features. They're temporary by design — the flag exists while the feature stabilizes, then gets removed. The mistake I see most often is teams treating release flags as permanent configuration knobs. When a flag has been at 100% for three months, nobody remembers which code path is the "real" one, and your test matrix silently doubles.
In practice, this means setting a removal date the moment you create the flag. Our team attaches an expiration tag to every release flag and runs a weekly script that surfaces anything past its removal window. We borrowed from the FlagShark playbook here: flags older than 90 days that aren't operational kill switches get an automatic ticket filed.
Centralize your flag keys in a single file, it gives you a one-glance inventory and prevents the typo-driven debugging sessions that scattered string literals create:
// code/src/flags.js — single source of truth for all flag keys
// See companion project: code/src/flags.js
const FLAGS = {
// Kill switch: wraps the payment provider integration.
// Defaults to FALSE (safe path) if SDK is unreachable.
PAYMENT_PROVIDER_KILL_SWITCH: "ops_payments_new_provider",
// Release flag: gates the new checkout UI.
// Temporary — remove after 100% rollout + 14 days stable.
NEW_CHECKOUT_UI: "release_checkout_redesigned_ui",
// Experiment flag: percentage rollout of recommendation engine.
RECOMMENDATION_ENGINE: "experiment_recommendations_v2",
// Permission flag: enterprise-only feature.
ENTERPRISE_ANALYTICS: "permission_enterprise_analytics",
};
The naming convention follows a pattern: {type}_{team/domain}_{feature}_{detail}. This tells you at a glance what a flag does, who owns it, and when it should be removed. Release flags should be short-lived. Ops flags (kill switches) should be reviewed annually. Experiment flags expire when the experiment ends.
Here's the LaunchDarkly client initialization — a singleton that streams flag rules and caches them locally so evaluations work even during network interruptions:
// code/src/launchdarkly.js — LD client singleton
// See companion project: code/src/launchdarkly.js
const LaunchDarkly = require("@launchdarkly/node-server-sdk");
async function initLaunchDarkly(sdkKey) {
const ldClient = LaunchDarkly.init(sdkKey);
try {
await ldClient.waitForInitialization({ timeout: 5 });
console.log("[LaunchDarkly] Client initialized successfully");
} catch (err) {
console.warn(
"[LaunchDarkly] Initialization timed out — operating from cache or defaults"
);
}
return ldClient;
}
Kill Switches
A kill switch is a different animal entirely. It's not about shipping features — it's about operational safety. Every integration point with an external system, every experimental code path, every performance-sensitive refactor gets wrapped in one.
The pattern that saved us at 2:47 AM looked like this:
// code/src/server.js — Kill Switch pattern
// See companion project: code/src/server.js, GET /api/payment/status
app.get("/api/payment/status", async (req, res) => {
const context = { kind: "user", key: req.query.user || req.ip };
// Default: false = use safe fallback path.
// If LaunchDarkly is unreachable, the SDK returns the default.
const useNewProvider = await client.boolVariation(
FLAGS.PAYMENT_PROVIDER_KILL_SWITCH,
context,
false // <-- THE CRITICAL DEFAULT: safe path
);
if (useNewProvider) {
return res.json({ provider: "new-payment-provider", status: "ok" });
}
// Safe fallback: the existing, battle-tested provider.
res.json({ provider: "existing-payment-provider", status: "ok" });
});
The critical design requirement: the fallback path must be the one that works. If your kill switch guards a new payment provider integration, the fallback routes through the existing, battle-tested provider. If the flag evaluation itself fails due to a network issue, LaunchDarkly's SDK returns the default value you specify — which should always trigger the safe path.
Percentage Rollouts
Deterministic hashing based on a stable user attribute means the same user sees the same experience across sessions. This matters more than you'd think — users notice inconsistency, and your metrics become meaningless if a single user bounces between variants.
Our rollout cadence settled into a rhythm: internal team for one day, 1% of external users for a day, then 5%, 25%, and full release if all guardrails stay green. At each stage, we watch application error rates, API latency, and business metrics. LaunchDarkly's Guarded Releases can automate the pause-or-rollback decision if a threshold breaches, which removes the 3 AM judgment call from the equation.
// code/src/server.js — Percentage rollout with string variation
// See companion project: code/src/server.js, GET /api/recommendations
app.get("/api/recommendations", async (req, res) => {
const context = { kind: "user", key: req.query.user || "anonymous" };
// stringVariation for multi-variant experiments.
// Deterministic hashing on user key ensures the same user
// consistently sees the same variant.
const variant = await client.stringVariation(
FLAGS.RECOMMENDATION_ENGINE,
context,
"v1" // default: existing recommendation engine
);
if (variant === "v2") {
return res.json({
engine: "collaborative-filtering-v2",
recommendations: ["Item-A", "Item-B", "Item-C"],
});
}
res.json({
engine: "popularity-based-v1",
recommendations: ["Item-X", "Item-Y", "Item-Z"],
});
});
And here's user targeting in action — enterprise features gated by a custom attribute:
// code/src/server.js — Targeting with custom attributes
// See companion project: code/src/server.js, GET /api/analytics/dashboard
app.get("/api/analytics/dashboard", async (req, res) => {
const context = {
kind: "user",
key: req.query.user || "anonymous",
plan: req.query.plan || "free", // custom attribute for targeting rules
};
const canAccess = await client.boolVariation(
FLAGS.ENTERPRISE_ANALYTICS,
context,
false
);
if (!canAccess) {
return res.status(403).json({
error: "Enterprise analytics require the Enterprise plan.",
});
}
res.json({
dashboard: "advanced-analytics",
metrics: ["revenue-per-user", "churn-prediction", "cohort-retention"],
});
});
All the code above comes from the companion project — a fully runnable Express app in code/src/server.js. Clone it, set your SDK key, and you'll see every pattern respond to flag toggles in real time without a server restart.
The Questions Your Team Will Ask (And How to Answer Them)
When you introduce feature flags at scale, you'll hear the same objections. I've had these conversations enough times to recognize the patterns.
"Doesn't this just create more code to maintain?"
Yes, if you treat flags as permanent. The entire discipline of flag lifecycle management exists because flags without expiration dates become technical debt with a feature flag logo. The countermeasure is mechanical, not cultural: automation that flags stale toggles, creates cleanup tasks, and blocks new flags when the ratio of creation to removal tips past 2:1.
We enforce a simple rule: every flag has an owner, an expiration date, and a ticket filed at creation time for its eventual removal. When a release flag hits 100% rollout for two weeks, the cleanup PR gets auto-generated. This isn't optional — it's how you prevent the flag graveyard.
"What if the flag service goes down?"
LaunchDarkly SDKs maintain a streaming connection and cache flag rules locally. If the connection drops, evaluations continue against the cached ruleset. The boolVariation call includes a default value parameter precisely for this scenario — and every code path I write defaults to the safe, existing behavior.
In the 2:47 AM scenario, the kill switch worked because the SDK had already cached the flag state. Even if LaunchDarkly's service had been unavailable at that exact moment, the toggle would have still evaluated correctly against the local cache.
"Can't we just build this ourselves?"
Technically, yes. I've seen teams build internal feature flag systems. I've also seen those same teams spend sprint after sprint maintaining edge-case evaluation logic, building dashboards, and debugging deterministic hashing when they could have been building their actual product. The key consideration here isn't whether you can build it — it's whether maintaining a feature flag platform is where your team's time creates the most value.
Where We Go From Here
If you're starting with feature flags, begin with one operational kill switch on a high-risk integration. Get comfortable with the pattern, build the muscle memory for flag cleanup, then expand to release flags and progressive rollouts. The most successful adoptions I've seen started small and grew organically, rather than attempting a company-wide flag-everything initiative overnight.
For deeper dives, the LaunchDarkly documentation on guarded rollouts and kill switch flags is excellent. The FlagShark best practices guide informed much of our internal naming and lifecycle discipline. And if you want to understand why stale flags genuinely keep me up at night, read about the $460M Knight Capital incident — a stark reminder that unreachable code paths aren't harmless.
The original version of this article, along with a companion project demonstrating every pattern discussed here, lives on this blog. I'll be expanding it based on your questions and feedback before it goes to LeadDev and DZone — so if something here sparks a thought or a disagreement, I'd genuinely like to hear it in the comments.
Key Takeaways
Separate deployment from release. A deployed change that isn't live yet is a safety net. A deployed change that's fully live with no way to turn it off is a liability.
Treat flag cleanup as a first-class engineering practice. Naming conventions, expiration dates, and automated removal aren't overhead — they're what keep your codebase comprehensible six months from now.
Default to safety. Every flag evaluation should fall back to the known-good path. The time to verify your kill switch works isn't during an incident at 2:47 AM.
Start small, automate early, and build the habits before you build the flag count. The teams I've watched succeed with feature flags aren't the ones with the most sophisticated tooling — they're the ones with the most disciplined lifecycle management.
This article was originally published by DEV Community and written by Pravin Khandke.
Read original article on DEV Community