NestJS Feature Flags Without External Services
You want to gate a new feature behind a flag. The usual options: LaunchDarkly ($$$), Unleash (self-hosted complexity), or a homegrown if (process.env.ENABLE_FEATURE) that requires a redeploy to change.
What if your PostgreSQL database — the one you already have — could be your feature flag store?
The Problem with Environment Variables
// The simplest approach — but painful in practice
@Get('analytics')
async analytics() {
if (process.env.ENABLE_ANALYTICS !== 'true') {
throw new ForbiddenException();
}
return this.analyticsService.getDashboard();
}This works until you need:
- Per-tenant flags — Tenant A gets the feature, Tenant B doesn't
- Gradual rollout — Enable for 10% of users, then 50%, then 100%
- Instant toggle — Turn off a broken feature without redeploying
- User overrides — QA team needs access before launch
Each requirement pushes you toward either paying for a SaaS flag service or building your own flag system.
Database-Backed Flags
The approach: store flags in PostgreSQL, evaluate them at request time, cache aggressively.
-- Two tables: flags and overrides
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT UNIQUE NOT NULL,
enabled BOOLEAN DEFAULT false,
percentage INT DEFAULT 0,
archived_at TIMESTAMPTZ
);
CREATE TABLE feature_flag_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
flag_id UUID REFERENCES feature_flags(id) ON DELETE CASCADE,
attributes JSONB NOT NULL,
enabled BOOLEAN NOT NULL,
priority INT NOT NULL DEFAULT 0
);Version 0.5 uses attribute-based overrides rather than fixed tenant, user, and environment columns. Top-level evaluation context values are merged into the attribute set. The evaluation priority chain has four layers:
- Archived? → always false
- Best matching attribute override → specificity, priority, creation time, then ID
- Percentage rollout? → deterministic hash
- Global default →
enabledfield
The Decorator Pattern
Instead of if/else in every controller, a decorator-based guard:
@Get('analytics')
@FeatureFlag('PREMIUM_ANALYTICS')
async analytics() {
// Only executes if flag is enabled for the current context
return this.analyticsService.getDashboard();
}The guard resolves the configured evaluation context, evaluates the four-layer cascade, and returns 403 if the flag is off.
Performance: Cache Is Everything
Without caching, every @FeatureFlag() check hits the database. With a 30-second TTL in-memory cache:
| Scenario | Latency |
|---|---|
| Cache hit | 0.04ms |
| Cache miss (DB lookup) | 1.17ms |
That's a 29.2x speedup in the current benchmark. Results were measured on Apple Silicon with PostgreSQL 16, Prisma 7.9.1, and local Docker; your results will vary.
Using @nestarc/feature-flag
This is exactly what @nestarc/feature-flag implements:
npm install @nestarc/feature-flag// app.module.ts
FeatureFlagModule.forRoot({
environment: 'production',
prisma,
cacheTtlMs: 30_000,
userIdExtractor: (req) => req.headers['x-user-id'],
}),Includes the Prisma schema, evaluation engine, NestJS guard, cache layer, and event system — all in one package.