Feature Flags and Safe Rollouts
- feature-flags
- saas
- nodejs
- nestjs
- postgresql
- redis
- devops
- software-architecture
In Audit Logs You Can Trust, we built a system that answers "who changed what, and when." Feature flags answer a related question: "who gets to see this, and how much of it." Once you trust your audit trail, the natural next step is controlling what actually ships behind it. A flag toggle is exactly the kind of change your audit log should be recording.
This is part of the Full Stack SaaS Masterclass, a build-it-for-real series that takes a multi-tenant SaaS from an empty repo to production. We're deep into Module 4 now: the unglamorous plumbing that separates a demo from something you can run a business on.
Feature flags get a bad reputation because teams either skip them and ship risky all-or-nothing deploys, or bolt on a full experimentation platform before they have a single paying customer. What you actually need, most of the time, is much simpler than either extreme.
Why feature flags earn their place
The core problem a flag solves is decoupling deployment from release. Without flags, "merge to main" and "customers see this" are the same event. That's fine until it isn't: a half-finished billing rewrite sits in a long-lived branch for weeks, and the eventual merge is a nerve-wracking event instead of a routine one.
With flags, you can merge incomplete work behind a flag that's off in production. The code ships continuously; the behavior ships when you decide it's ready. That single decoupling is worth more than the fancier things flags enable later, like percentage rollouts or experiments.
There's a second benefit that gets less attention: flags give you a fast, code-free rollback. If a new feature causes an incident, flipping a boolean in the flags table is a lot faster than reverting a deploy and waiting for CI to build and ship a new image. For anything touching payments, auth, or data integrity, that speed difference matters.
The tradeoff is real, though. Flags are conditional logic, and conditional logic nobody cleans up is technical debt with a timer running. A flag left in after a full rollout becomes a permanent if statement nobody remembers the reason for. Codebases accumulate a dozen "temporary" flags from a year ago, still checked on every request, still branching in tests nobody wants to write. Plan for removal on the day you add the flag, not as an afterthought.
Flag types, and picking the right one
Not every flag needs the same lifecycle or the same evaluation logic. Lumping them together is how flag systems become unmanageable. I find it useful to separate them into four categories:
Release toggles guard incomplete or risky features during rollout. They're meant to be short-lived: turn on, verify, delete the flag and the branching code within a sprint or two.
Ops toggles (kill switches) protect the system itself. Think "disable the recommendation engine if the third-party API is down" or "turn off background email digests during a database migration." These can live indefinitely; they're a safety valve, not a rollout mechanism.
Permission toggles gate a feature by plan tier or organization, like exposing SSO configuration only to enterprise customers. These are effectively long-lived entitlements, closer to authorization than to a rollout flag, and they usually deserve their own model rather than reusing your rollout table.
Experiment toggles split traffic to compare outcomes. This is the category people usually mean by "feature flags" in a growth context, and it carries the most operational overhead: consistent bucketing, an analytics pipeline, and a plan for what happens when the experiment ends. Don't reach for it until you have a hypothesis worth testing and enough traffic for a meaningful signal.
For most SaaS teams before product-market fit, release toggles and a handful of ops toggles cover nearly everything. Permission toggles come naturally once you have paid tiers. Experiment toggles are the category worth deferring the longest.
Building a flag service: Postgres and Redis
You don't need a vendor for this. A table, a cache, and a small service take you a long way, and the mental model stays simple: flags are just rows you query and update like anything else in your system.
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage SMALLINT NOT NULL DEFAULT 0
CHECK (rollout_percentage BETWEEN 0 AND 100),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE feature_flag_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
flag_id UUID NOT NULL REFERENCES feature_flags(id) ON DELETE CASCADE,
organization_id UUID NOT NULL,
enabled BOOLEAN NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (flag_id, organization_id)
);
The overrides table matters more than it looks. It's how you handle "turn this on for one pilot customer" without touching the global rollout percentage. Overrides always win over the percentage rule, and that precedence needs to be explicit in the evaluation logic.
Evaluation happens on every request that touches a flagged code path, so it needs to be fast and it needs to survive a Redis outage gracefully. Cache-aside with a short TTL handles the common case; a Postgres fallback handles the cache being cold or down.
// feature-flags.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createHash } from 'crypto';
import Redis from 'ioredis';
import { FeatureFlag } from './feature-flag.entity';
import { FeatureFlagOverride } from './feature-flag-override.entity';
interface FlagContext {
organizationId: string;
}
const CACHE_TTL_SECONDS = 30;
@Injectable()
export class FeatureFlagsService {
private readonly logger = new Logger(FeatureFlagsService.name);
constructor(
@InjectRepository(FeatureFlag)
private readonly flags: Repository<FeatureFlag>,
@InjectRepository(FeatureFlagOverride)
private readonly overrides: Repository<FeatureFlagOverride>,
private readonly redis: Redis,
) {}
async isEnabled(key: string, context: FlagContext): Promise<boolean> {
const flag = await this.getFlag(key);
if (!flag) {
return false;
}
const override = await this.getOverride(flag.id, context.organizationId);
if (override) {
return override.enabled;
}
if (!flag.enabled) {
return false;
}
if (flag.rolloutPercentage >= 100) {
return true;
}
if (flag.rolloutPercentage <= 0) {
return false;
}
return this.isInRolloutBucket(key, context.organizationId, flag.rolloutPercentage);
}
private isInRolloutBucket(key: string, organizationId: string, percentage: number): boolean {
const hash = createHash('sha256').update(`${key}:${organizationId}`).digest();
const bucket = hash.readUInt32BE(0) % 100;
return bucket < percentage;
}
private async getFlag(key: string): Promise<FeatureFlag | null> {
const cacheKey = `flag:${key}`;
const cached = await this.redis.get(cacheKey).catch((err) => {
this.logger.warn(`Redis unavailable for ${cacheKey}: ${err.message}`);
return null;
});
if (cached) {
return JSON.parse(cached) as FeatureFlag;
}
const flag = await this.flags.findOne({ where: { key } });
if (flag) {
await this.redis
.set(cacheKey, JSON.stringify(flag), 'EX', CACHE_TTL_SECONDS)
.catch(() => undefined);
}
return flag ?? null;
}
private async getOverride(flagId: string, organizationId: string) {
return this.overrides.findOne({ where: { flagId, organizationId } });
}
}
Hashing the flag key with the organization ID makes the rollout consistent: the same organization always lands in the same bucket for a given flag, so a customer doesn't flicker between "has the feature" and "doesn't" on every request. Changing the flag key changes the bucket assignment too, which stops one flag's rollout from correlating with another's in unplanned ways.
Wrap the check in a small guard or decorator so route handlers stay clean:
// feature-flag.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const FEATURE_FLAG_KEY = 'featureFlag';
export const RequireFeatureFlag = (key: string) => SetMetadata(FEATURE_FLAG_KEY, key);
// feature-flag.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { FeatureFlagsService } from './feature-flags.service';
import { FEATURE_FLAG_KEY } from './feature-flag.decorator';
@Injectable()
export class FeatureFlagGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly flags: FeatureFlagsService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const key = this.reflector.get<string>(FEATURE_FLAG_KEY, context.getHandler());
if (!key) {
return true;
}
const request = context.switchToHttp().getRequest();
return this.flags.isEnabled(key, { organizationId: request.organizationId });
}
}
Every write to feature_flags or feature_flag_overrides should go through the audit logging service from the previous article. "Who turned this on" is exactly the question you'll want answered during an incident retro.
Rollout strategy and rolling back
A percentage rollout only helps if you pair it with something to watch. Ramping 5 percent, then 25, then 100 over a few days catches a regression before it reaches everyone, but only if someone is actually watching error rates and support tickets during that window. A rollout plan without a monitoring plan is just a slower way to break things for everyone.
The other half of a safe rollout is a documented, low-friction path back to off: the person on call knows where the flags table lives, has permission to flip it, and doesn't need a deploy to do so. If your only rollback mechanism is redeploying an older image, you haven't actually decoupled deployment from release.
For genuinely risky operations, an ops toggle checked at the top of a job or worker, not just in the HTTP layer, is worth the extra wiring. A queue consumer checking isEnabled('new-billing-sync', ...) before processing a job can be paused independently of the API tier, which matters when the risky code runs outside a request-response cycle entirely.
Tradeoffs and where teams get burned
The most common failure mode is flag accumulation. Teams rarely get the implementation itself wrong. Every flag is a fork in your test matrix; ten flags produce something closer to two to the tenth power of possible states, most of which nobody tests. Set a rule early: release toggles get removed within a sprint or two of reaching 100 percent, with a backlog ticket created the day the flag is added.
Build versus buy is worth an honest look. Vendors like LaunchDarkly, Unleash, and Flagsmith solve real problems: multivariate flags, SDKs across languages, targeting UIs, and analytics tied to experiments. If your team runs many experiment-style flags with product managers who need a UI, that tooling earns its cost quickly. But release and ops toggles, which cover most of what a small SaaS team needs, are simpler to reason about with a Postgres table and a Redis cache, and they don't add another vendor to your uptime dependency graph. Reach for the vendor only when usage actually demands it.
A subtler pitfall: flags gating database schema changes. If a flag controls whether code writes to a new column, and you roll it back after data has already been written under the new behavior, you can end up with inconsistent rows that neither code path expects. Schema-affecting flags need a data rollback plan, not just a code rollback plan, and that plan should exist before the flag reaches 100 percent.
Key takeaways
- Feature flags decouple deployment from release, which turns merges into routine events and rollbacks into a config change instead of a redeploy.
- Separate flags by purpose: release toggles, ops toggles, permission toggles, and experiment toggles each have different lifecycles and different owners.
- A Postgres table plus a Redis cache handles release and ops toggles well; save vendor platforms for when you have real experimentation or targeting needs.
- Hash the flag key with a stable identifier (organization or user ID) for consistent, sticky percentage rollouts.
- Every flag needs a removal plan from the day it's created, and schema-affecting flags need a data rollback plan, not just a code rollback plan.
- Route flag changes through the same audit logging you already built; a toggle in production is a change worth recording.
Frequently asked questions
What's the difference between a feature flag and a kill switch?
A kill switch is a specific type of feature flag, usually what we'd call an ops toggle. It stays in the codebase indefinitely and exists to disable a risky or unstable code path quickly, rather than to manage a rollout.
Should feature flag state live in the database or in environment variables?
Environment variables require a redeploy or restart to change, which defeats the main benefit of flags: changing behavior without shipping code. Database-backed flags, cached in Redis, let you toggle behavior instantly while keeping a change history.
How long should a feature flag live before it's removed?
For a release toggle, days to a couple of sprints after it reaches full rollout. Ops toggles and permission toggles can live indefinitely by design, since they serve a different, ongoing purpose.
Do I need a third-party feature flag service like LaunchDarkly for a small SaaS?
Not at first. A flags table with a Redis cache covers release and ops toggles, which is what most small teams need. Consider a vendor once you need multivariate experiments, non-engineer-facing targeting UIs, or SDKs across many languages.
How do I test code that's behind a feature flag?
Write tests for both states explicitly, don't rely on the default. Treat the off state as current production behavior and the on state as the new behavior under test, and keep both covered until the flag is removed.
Can feature flags cause data inconsistency?
Yes, particularly when a flag controls a schema change or new write path. Rolling a flag back after it's written data under the new behavior can leave rows in a state neither code path handles. Plan the data rollback alongside the flag, not just the code rollback.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.