Feature Toggling with Environment-Based Config
The simplest way to ship a half-finished feature to production without exposing it is also the most commonly reinvented badly: a boolean, read from config, checked in an if. Before reaching for a feature-flag service with a dashboard and an SDK, it's worth knowing how far a plain environment-based toggle actually gets you — and it's further than most teams assume.
The core idea
A feature toggle is a runtime check that decides whether a code path executes. The flag's value comes from outside the code — environment variables, a config file, a database row — so you can change behavior without a new deploy.
const features = {
newCheckoutFlow: process.env.FEATURE_NEW_CHECKOUT === 'true',
betaExportApi: process.env.FEATURE_EXPORT_API === 'true',
};
function handleCheckout(req, res) {
if (features.newCheckoutFlow) {
return handleCheckoutV2(req, res);
}
return handleCheckoutV1(req, res);
}
That's the entire mechanism. Everything else — percentage rollouts, per-user targeting, kill switches — is refinement on top of "read a value, branch on it."
Why bother, instead of just deploying the feature
- Decouple deploy from release. Merge and deploy the code behind a flag set to
false, then flip it on separately — without a deploy — when you're actually ready. This turns releasing into a config change instead of a code change, which is faster to do and faster to undo. - Kill switches. If a new code path misbehaves in production, flipping a flag off is immediate. Rolling back a deploy takes longer and reverts everything in that deploy, not just the broken feature.
- Environment-specific behavior. Enable a feature in staging weeks before production, without maintaining a separate branch.
A config layer that scales past a handful of booleans
Scattering process.env.FEATURE_X === 'true' checks across the codebase works for two flags and becomes unmaintainable at ten. Centralize it:
// config/features.js
function bool(name, fallback = false) {
const value = process.env[name];
if (value === undefined) return fallback;
return value === 'true' || value === '1';
}
module.exports = {
newCheckoutFlow: bool('FEATURE_NEW_CHECKOUT'),
betaExportApi: bool('FEATURE_EXPORT_API'),
strictRateLimit: bool('FEATURE_STRICT_RATE_LIMIT', true), // defaults on
};
Now every call site imports from one place, every flag has one line documenting its default, and adding a flag is a one-line change.
const features = require('./config/features');
if (features.betaExportApi) {
app.use('/api/export', exportRouter);
}
Handling flags that need per-user targeting
Environment variables are global — every request on that process sees the same value. That's fine for "is this feature live in production at all" but not for "show this to 5% of users" or "show this to internal accounts only." For that, the flag check needs a piece of request context:
function isEnabledForUser(flagName, user) {
if (!features[flagName]) return false;
// internal accounts always see flags that are otherwise off for everyone
if (user?.isInternal) return true;
// deterministic percentage rollout: same user always gets the same bucket
const bucket = hashToPercent(`${flagName}:${user.id}`);
return bucket < rolloutPercent(flagName);
}
function hashToPercent(input) {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
}
return hash % 100;
}
The important detail is hashToPercent being deterministic per user, not random per request — a user in the 5% rollout should stay in it on every request, or you get an inconsistent experience (the new UI on one page load, the old one on the next) that's confusing to users and impossible to debug from a bug report.
Where flags go to die
The failure mode of feature flags isn't adding them — it's never removing them. A flag that's been true in every environment for six months is dead weight: it's a branch nobody tests the false side of anymore, a condition every future reader has to mentally evaluate, and a source of "wait, is this code even reachable?" during debugging.
Treat a flag as a temporary construct with an expected lifetime:
- Name it for the feature, not the ticket number (
newCheckoutFlow, notPROJ-4821) — it should be self-explanatory a year later. - When a flag has been at 100% in production for a while with no incidents, that's a ticket to delete the flag and the old code path, not a permanent state.
- Flags meant to be genuinely permanent (an ops kill switch, a per-environment toggle) are a different category — name and document them differently so nobody tries to "clean them up."
When environment variables stop being enough
Plain env-based flags hit a real ceiling: changing one still requires touching the deploy environment (a redeploy, or at minimum a process restart to pick up the new value), and there's no audit log of who flipped what, when. That's the point where a dedicated flag service (LaunchDarkly, Unleash, or a simple flags table in your own database with an admin UI) earns its complexity — instant toggling without a deploy, per-user rules without hand-rolled hashing, and a history of changes.
Don't reach for that on day one, though. A dozen environment variables read through a small config module will comfortably carry a project for a long time, and migrating the concept of a flag (a name, a boolean, call sites that check it) to a real flag service later is a mechanical change, not a redesign.
Takeaway
A feature flag is just an if whose condition lives outside the code. Centralize the checks, make percentage rollouts deterministic per user, and — the part everyone skips — actually delete flags once they've settled at 100%. The infrastructure to manage flags at scale is optional; the discipline to remove them isn't.