Feature Flags Without a $500/Month Platform: Build Your Own in Node.js
A full production feature flag system built on Postgres and Redis — percentage rollouts, tenant targeting, two-layer caching, and the hygiene that prevents flag debt
Senior Developer

Feature flags are one of those tools that sound like enterprise overhead until you ship a broken feature to all your users at once and spend 45 minutes rolling back a deploy. After that, they sound essential.
The commercial platforms — LaunchDarkly, Split, Unleash Cloud — are excellent but expensive. LaunchDarkly starts at several hundred dollars a month for teams. For most early-stage products, that is a hard sell when the underlying concept is not complicated.
This guide builds a production-ready feature flag system from scratch: flags stored in Postgres, cached in Redis, evaluated per-user or per-tenant, with a simple admin API and a client that degrades gracefully when the flag store is unavailable.
What a Feature Flag System Actually Needs
At minimum:
Flag storage — a record of which flags exist and their current state
Evaluation — given a flag and a context (user, tenant, environment), return on or off
Targeting — some flags should be on for 10% of users, or only for beta testers, or only for a specific tenant
Caching — flag checks happen on every request; they cannot hit the database every time
Fallback — if the flag store is unavailable, the system should degrade safely
Optionally: an admin UI, audit logs, scheduled flag expiry.
The Database Schema
CREATE TYPE flag_status AS ENUM ('active', 'archived');
CREATE TYPE rule_type AS ENUM ('percentage', 'user_ids', 'tenant_ids', 'attribute');
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL UNIQUE, -- 'new-checkout-flow', 'dark-mode'
description TEXT,
status flag_status NOT NULL DEFAULT 'active',
enabled BOOLEAN NOT NULL DEFAULT false, -- Global on/off
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Targeting rules — evaluated in order, first match wins
CREATE TABLE flag_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
flag_id UUID NOT NULL REFERENCES feature_flags(id) ON DELETE CASCADE,
rule_type rule_type NOT NULL,
value JSONB NOT NULL, -- Rule data (e.g. percentage, list of IDs)
result BOOLEAN NOT NULL, -- What to return if this rule matches
priority INTEGER NOT NULL DEFAULT 0, -- Lower = evaluated first
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_flag_rules_flag_id ON flag_rules(flag_id);
-- Audit log
CREATE TABLE flag_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
flag_key TEXT NOT NULL,
action TEXT NOT NULL,
changed_by UUID,
old_value JSONB,
new_value JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Seed Some Flags
INSERT INTO feature_flags (key, description, enabled) VALUES
('new-checkout-flow', 'Redesigned checkout with one-page layout', false),
('ai-suggestions', 'AI-powered search suggestions in header', false),
('dark-mode', 'Dark mode UI toggle', true),
('export-csv', 'CSV export on all data tables', false);
-- Enable new-checkout-flow for 20% of users
INSERT INTO flag_rules (flag_id, rule_type, value, result, priority)
SELECT id, 'percentage', '{"percentage": 20}'::jsonb, true, 0
FROM feature_flags WHERE key = 'new-checkout-flow';
-- Enable ai-suggestions for specific beta tenant IDs
INSERT INTO flag_rules (flag_id, rule_type, value, result, priority)
SELECT id, 'tenant_ids',
'{"ids": ["uuid-tenant-1", "uuid-tenant-2"]}'::jsonb,
true, 0
FROM feature_flags WHERE key = 'ai-suggestions';The Flag Evaluator
// src/lib/featureFlags.ts
import db from './db';
import redis from './redis';
import crypto from 'crypto';
import logger from './logger';
interface EvaluationContext {
userId?: string;
tenantId?: string;
email?: string;
plan?: string;
[key: string]: string | undefined; // Custom attributes
}
interface FlagRule {
rule_type: 'percentage' | 'user_ids' | 'tenant_ids' | 'attribute';
value: Record<string, any>;
result: boolean;
priority: number;
}
interface Flag {
key: string;
enabled: boolean;
status: string;
rules: FlagRule[];
}
// Cache flag configs — refreshed every 60 seconds
const FLAG_CACHE_TTL = 60;
const LOCAL_CACHE = new Map<string, { data: Flag; expiresAt: number }>();
async function getFlagConfig(flagKey: string): Promise<Flag | null> {
// L1: in-process cache (fastest, no network)
const local = LOCAL_CACHE.get(flagKey);
if (local && local.expiresAt > Date.now()) {
return local.data;
}
// L2: Redis cache
try {
const cached = await redis.get(`flag:${flagKey}`);
if (cached) {
const flag = JSON.parse(cached);
LOCAL_CACHE.set(flagKey, {
data: flag,
expiresAt: Date.now() + 30_000, // Local cache for 30s
});
return flag;
}
} catch (err) {
logger.warn({ flagKey }, 'Redis unavailable for flag lookup');
}
// L3: Database
try {
const result = await db.query(`
SELECT
f.key, f.enabled, f.status,
COALESCE(
json_agg(
json_build_object(
'rule_type', r.rule_type,
'value', r.value,
'result', r.result,
'priority', r.priority
) ORDER BY r.priority ASC
) FILTER (WHERE r.id IS NOT NULL),
'[]'
) AS rules
FROM feature_flags f
LEFT JOIN flag_rules r ON r.flag_id = f.id
WHERE f.key = $1 AND f.status = 'active'
GROUP BY f.key, f.enabled, f.status
`, [flagKey]);
if (!result.rows[0]) return null;
const flag = result.rows[0] as Flag;
// Populate both caches
try {
await redis.setex(`flag:${flagKey}`, FLAG_CACHE_TTL, JSON.stringify(flag));
} catch {}
LOCAL_CACHE.set(flagKey, {
data: flag,
expiresAt: Date.now() + 30_000,
});
return flag;
} catch (err) {
logger.error({ flagKey, error: (err as Error).message }, 'Flag DB lookup failed');
return null;
}
}
function evaluateRules(rules: FlagRule[], context: EvaluationContext): boolean | null {
for (const rule of rules) {
switch (rule.rule_type) {
case 'user_ids': {
if (context.userId && rule.value.ids?.includes(context.userId)) {
return rule.result;
}
break;
}
case 'tenant_ids': {
if (context.tenantId && rule.value.ids?.includes(context.tenantId)) {
return rule.result;
}
break;
}
case 'percentage': {
if (!context.userId) break;
// Hash the user ID to get a stable 0-100 bucket
// Same user always gets the same result — consistent experience
const hash = crypto
.createHash('sha256')
.update(context.userId)
.digest('hex');
const bucket = parseInt(hash.slice(0, 8), 16) % 100;
if (bucket < rule.value.percentage) {
return rule.result;
}
break;
}
case 'attribute': {
const { attribute, operator, value } = rule.value;
const contextValue = context[attribute];
if (contextValue === undefined) break;
const match = operator === 'equals' ? contextValue === value
: operator === 'not_equals' ? contextValue !== value
: operator === 'contains' ? contextValue.includes(value)
: operator === 'in' ? value.includes(contextValue)
: false;
if (match) return rule.result;
break;
}
}
}
return null; // No rules matched
}
/**
* Check if a feature flag is enabled for a given context.
* @param flagKey The flag key (e.g. 'new-checkout-flow')
* @param context User/tenant context for targeting rules
* @param defaultValue What to return if the flag is not found (safe default)
*/
export async function isEnabled(
flagKey: string,
context: EvaluationContext = {},
defaultValue: boolean = false
): Promise<boolean> {
try {
const flag = await getFlagConfig(flagKey);
if (!flag) return defaultValue; // Flag not found — use default
if (!flag.enabled) return false; // Globally disabled
// Evaluate targeting rules — first match wins
const ruleResult = evaluateRules(flag.rules, context);
if (ruleResult !== null) return ruleResult;
// No rules matched — use global enabled state
return flag.enabled;
} catch (err) {
// If anything fails, return the safe default
// Never let a flag check crash your application
logger.error({ flagKey, error: (err as Error).message }, 'Flag evaluation error');
return defaultValue;
}
}
/**
* Check multiple flags at once — one cache/DB round trip
*/
export async function getFlags(
flagKeys: string[],
context: EvaluationContext = {}
): Promise<Record<string, boolean>> {
const results = await Promise.all(
flagKeys.map(async (key) => [key, await isEnabled(key, context)] as const)
);
return Object.fromEntries(results);
}
/**
* Invalidate the cache for a flag — call this after updating a flag
*/
export async function invalidateFlag(flagKey: string): Promise<void> {
LOCAL_CACHE.delete(flagKey);
await redis.del(`flag:${flagKey}`);
}Usage in Your Application
import { isEnabled, getFlags } from '../lib/featureFlags';
// Simple check in a route handler
router.get('/checkout', authenticate, async (req, res) => {
const useNewCheckout = await isEnabled('new-checkout-flow', {
userId: req.user.id,
tenantId: req.tenant?.id,
plan: req.tenant?.plan,
});
res.json({
checkoutVersion: useNewCheckout ? 'v2' : 'v1',
});
});
// Check multiple flags at once (one round trip)
router.get('/app-config', authenticate, async (req, res) => {
const context = {
userId: req.user.id,
tenantId: req.tenant?.id,
plan: req.tenant?.plan,
email: req.user.email,
};
const flags = await getFlags([
'new-checkout-flow',
'ai-suggestions',
'dark-mode',
'export-csv',
], context);
// Send flag state to frontend — client uses this to show/hide features
res.json({ flags });
});The Admin API
// src/routes/admin/flags.ts
router.get('/admin/flags', authenticate, requireRole('admin'), async (req, res) => {
const flags = await db.query(`
SELECT f.*, COUNT(r.id) AS rule_count
FROM feature_flags f
LEFT JOIN flag_rules r ON r.flag_id = f.id
WHERE f.status = 'active'
GROUP BY f.id
ORDER BY f.key
`);
res.json(flags.rows);
});
router.patch('/admin/flags/:key', authenticate, requireRole('admin'), async (req, res) => {
const { enabled } = req.body;
const { key } = req.params;
const [flag] = await db.query(
`UPDATE feature_flags SET enabled = $1, updated_at = NOW()
WHERE key = $2 RETURNING *`,
[enabled, key]
).then(r => r.rows);
if (!flag) return res.status(404).json({ error: 'Flag not found' });
// Audit the change
await db.query(
`INSERT INTO flag_audit_log (flag_key, action, changed_by, old_value, new_value)
VALUES ($1, 'toggle', $2, $3, $4)`,
[key, req.user.id, { enabled: !enabled }, { enabled }]
);
// Bust the cache
await invalidateFlag(key);
res.json(flag);
});Flag Hygiene: The Discipline That Prevents Flag Debt
Feature flags accumulate. After six months you have 40 flags, nobody knows which are still in use, and the evaluation logic in your codebase has become a maze of nested conditionals.
Name flags for the transition, not the feature. new-checkout-flow tells you this is a temporary flag for a migration. checkout-v2 sounds permanent.
Set an expiry date when you create the flag. Add an expires_at column and alert when flags are past their intended lifetime:
ALTER TABLE feature_flags ADD COLUMN expires_at TIMESTAMPTZ;
-- Weekly job: alert on expired flags
SELECT key, description, expires_at
FROM feature_flags
WHERE expires_at < NOW() AND status = 'active';Delete flags when features are fully rolled out. Once you ship to 100% of users and the old code path is removed, delete the flag and its evaluation checks from the codebase. Flags are temporary by definition.
Limit concurrent active flags. If you have more than 10–15 active flags at once, the combination of states becomes difficult to reason about. Treat flag debt the same way you treat technical debt.
Comments (0)
Login to post a comment.