Feature Flags in NestJS: Progressive Rollouts and Kill Switches

Photo by zombieite on flickr
Start with a single injectable service that holds a map of flag rules and exposes an isEnabled method. It only needs a rollout percentage and a stable stickiness key such as tenant id at first. You can add tenant targeting and a kill switch check later without changing how the rest of the app calls the service.
Without a stable identity behind the bucketing, the same user could see the new behavior on one request and the old behavior on the next, which breaks any feature that spans multiple requests. Hashing a stickiness key such as user id or tenant id together with the flag key guarantees the same identity always lands in the same bucket.
Per tenant is usually the safer default for B2B and ERP products. If different users inside the same company see inconsistent behavior, it creates confusing support tickets and inconsistent audit trails, even though each individual user was technically bucketed correctly by the rollout percentage.
A kill switch is a flag whose evaluation always overrides every other rule, including rollout percentage and tenant targeting, so it can turn a feature off instantly during an incident. A regular rollout flag is meant to gradually turn a feature on, while a kill switch exists purely to turn one off fast without a redeploy.
Assign an owner and an expected removal date to every flag when it is created, and schedule flag removal as its own small pull request once a rollout reaches one hundred percent and holds for a full release cycle. Reviewing the flag list in a recurring engineering sync keeps cleanup from depending on someone remembering it.

Photo by zombieite on flickr
Environment variables are fine for toggling a feature between staging and production, but they fall apart the moment you need to ship a change gradually. A NestJS backend that only knows about process.env cannot answer questions like should this tenant see the new invoicing flow, or should ten percent of requests hit the new pricing engine while the rest keep using the old one. Those questions need runtime evaluation, not a redeploy.
This post walks through adding real feature flag evaluation to a NestJS service: a small evaluator that supports percentage rollouts with stable stickiness, per-tenant allow and deny lists, and a kill switch that always wins regardless of rollout state. It also covers the operational side that most tutorials skip, how to keep a growing flag list from turning into permanent branching logic nobody wants to clean up.
Environment variables are process-wide and require a restart or redeploy to change. That is acceptable for configuration that rarely changes, like a database connection string, but it is the wrong tool for a feature you want to enable for five percent of traffic today and eighty percent tomorrow. Every incremental step becomes a deployment, which slows down the exact thing progressive rollout is supposed to speed up: fast, low risk iteration.
A proper feature flag layer separates the question of what code exists in the deployed artifact from the question of what behavior a given request should see. The code for both the old and new path ships together, and a lightweight evaluation step decides which one runs, per request, per tenant, or per user, based on rules that can change in seconds without touching the build pipeline.
You do not need a paid vendor to get real rollout behavior. A single NestJS injectable that holds flag rules and exposes an isEnabled method covers most internal use cases, and the same interface can later be swapped for a hosted provider such as Unleash or an OpenFeature compatible backend without touching call sites.
// flags/flag-evaluator.service.ts
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
interface FlagRule {
key: string;
enabled: boolean;
rolloutPercentage: number; // 0-100
tenantAllowList?: string[];
tenantDenyList?: string[];
killSwitch?: boolean;
}
@Injectable()
export class FlagEvaluatorService {
constructor(private readonly rules: Map<string, FlagRule>) {}
isEnabled(flagKey: string, stickyId: string, tenantId?: string): boolean {
const rule = this.rules.get(flagKey);
if (!rule) return false;
// Kill switch always wins, regardless of rollout state
if (rule.killSwitch) return false;
if (!rule.enabled) return false;
if (tenantId && rule.tenantDenyList?.includes(tenantId)) return false;
if (tenantId && rule.tenantAllowList?.includes(tenantId)) return true;
return this.hashBucket(flagKey, stickyId) < rule.rolloutPercentage;
}
// Deterministic 0-99 bucket so the same stickyId always
// lands in the same bucket for a given flag.
private hashBucket(flagKey: string, stickyId: string): number {
const hash = createHash('sha256')
.update(`${flagKey}:${stickyId}`)
.digest('hex');
return parseInt(hash.slice(0, 8), 16) % 100;
}
}The important detail is the hashing step. Instead of calling a random number generator on every request, the evaluator hashes a stable identifier, such as a user id or tenant id, together with the flag key, and buckets the result into a number between zero and ninety nine. That bucket is deterministic: the same user always lands in the same bucket for the same flag, so a user who is in the rollout during one request stays in the rollout on the next request, even though no state was stored anywhere.
Cache flag rule lookups in memory and refresh them on an interval or via a pub or sub invalidation event, rather than hitting your flag store on every request. Evaluation itself should be a pure, in-process function once the rules are loaded, so it costs microseconds, not a network round trip.
A percentage rollout only works if the same identity keeps getting the same answer. Without a stable stickiness key, a user could see the new checkout flow on one request and the old one on the next, which breaks any feature that spans more than a single request, like a multi-step wizard or an in-progress cart.
| Stickiness key | What stays consistent | Best used for |
|---|---|---|
| User id | Same logged in user always gets the same bucket | Account level features, UI redesigns, pricing changes |
| Tenant id | Every user inside a company or workspace shares one bucket | B2B and ERP rollouts where inconsistent behavior inside one company causes support tickets |
| Session id | Consistent for the current session only, resets on next login | Short lived experiments and anonymous traffic |
For most backend services, tenant id is the right stickiness key, not user id. If half the accounting team at a company sees a new approval workflow and the other half does not, you get confused support tickets and inconsistent audit trails, even though each individual user was technically bucketed correctly.
Percentage rollouts answer how much of your traffic sees a change, but targeting answers who specifically should or should not see it regardless of the percentage. This matters most in B2B and ERP contexts, where a single enterprise customer might need a feature held back until their finance team has signed off, while a friendly pilot customer needs it turned on early for feedback.
Evaluate targeting rules before the percentage rollout check, and always let deny lists win over allow lists and over the rollout percentage. That ordering matches how support teams actually reason about incidents: the first question after a bug report is usually whether this tenant should have had the feature at all.
Avoid storing a growing list of individual tenant ids inline in every flag rule. It works fine at ten tenants and becomes an unreadable, unauditable blob at a few hundred. Group tenants into named segments such as pilot-customers or enterprise-tier once the list grows past a handful of entries, and reference the segment from the flag instead.
A kill switch is a feature flag with exactly one job: turn a feature off immediately when something goes wrong in production, without requiring a rollback or a new deployment. The evaluator shown earlier checks the kill switch before anything else, including the rollout percentage and tenant targeting, because during an incident you want one lever that overrides every other rule instantly.
The discipline that makes kill switches actually useful is deciding in advance which code paths get one. Wrapping every new feature in a flag is overkill, but any change that touches billing, external API calls with rate limits, or a data migration that runs on request should ship behind a flag you can flip from a dashboard or a single API call, not a change you have to redeploy to undo.
Test the kill switch path in staging before the feature ships, not after an incident forces you to use it for the first time in production. A kill switch nobody has exercised is a rollback plan you are hoping works.
Every flag you add is a fork in your codebase that someone has to maintain until it is removed. A backend with two years of accumulated flags, most of them fully rolled out and forgotten, is harder to reason about than one with no flags at all, because every code path has to be read with the question does this still branch on something.
Treated this way, feature flags stay a release tool instead of becoming a second, informal configuration system nobody fully understands. The goal is always the same: ship progressively, recover instantly if something breaks, and leave the codebase simpler once the rollout is finished than it would have been with a big bang deployment.