Feature flags let you ship code to production without activating it, roll out to a percentage of users, and disable functionality without a redeploy. This guide builds a complete implementation in Node.js and PostgreSQL -- no external SaaS platform required.
The full implementation fits in under 100 lines of application code and takes a few hours to ship.
Step 1: Create the Flags Table
Start with a simple PostgreSQL table. The schema needs four columns:
CREATE TABLE feature_flags (
id SERIAL PRIMARY KEY,
flag_key VARCHAR(100) UNIQUE NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT false,
rollout_pct SMALLINT NOT NULL DEFAULT 100
CHECK (rollout_pct BETWEEN 0 AND 100),
description TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for fast key lookups
CREATE INDEX idx_feature_flags_key ON feature_flags (flag_key);
The rollout_pct column controls what percentage of users see the flag when it is enabled. A value of 100 means all users; 10 means 10 percent of users, distributed deterministically by user ID.
Seed a test flag:
INSERT INTO feature_flags (flag_key, is_enabled, rollout_pct, description)
VALUES ('new_dashboard', false, 100, 'Redesigned dashboard view');
Step 2: Build the Flag Evaluator
The evaluator takes a flag key and an optional user ID, queries the table, and returns a boolean. The key decision is how to handle percentage rollouts -- the approach here uses a stable hash so the same user always gets the same result.
// lib/featureFlags.js
const db = require('./db'); // your database connection module
function stableHash(input) {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
}
return hash % 100;
}
async function isEnabled(flagKey, userId = null) {
try {
const result = await db.query(
'SELECT is_enabled, rollout_pct FROM feature_flags WHERE flag_key = $1',
[flagKey]
);
if (result.rows.length === 0) return false;
const { is_enabled, rollout_pct } = result.rows[0];
if (!is_enabled) return false;
if (rollout_pct >= 100) return true;
const bucket = stableHash(`${flagKey}:${userId ?? 'anon'}`);
return bucket < rollout_pct;
} catch {
return false; // fail closed: missing/broken flags stay inactive
}
}
module.exports = { isEnabled };
A few design decisions worth noting:
Fail closed on errors. Any database error or unexpected exception returns false. Code behind a flag stays inactive if the flag system is broken, which prevents a flag system outage from activating half-deployed features.
Deterministic bucketing. The hash of flagKey + userId always produces the same result, so a user's flag state is consistent across requests. Using flagKey as part of the hash input also distributes different flags independently -- user 12345 does not end up in the same rollout bucket for every flag.
Default false for missing flags. If a flag key does not exist, the function returns false. This allows you to add flag checks in code before creating the corresponding row in the database -- useful when you want the flag off by default during initial deployment.
Step 3: Cache Flag State With Redis
For applications with significant traffic, the database round-trip on every flag evaluation becomes measurable. Add a Redis cache layer with a short TTL:
// lib/featureFlags.js (with Redis cache)
const redis = require('./redis'); // ioredis or redis@4 client
const db = require('./db');
const FLAG_TTL = 30; // seconds
async function getFlagFromCache(flagKey) {
const cached = await redis.get(`flag:${flagKey}`);
return cached !== null ? JSON.parse(cached) : null;
}
async function isEnabled(flagKey, userId = null) {
try {
let flagData = await getFlagFromCache(flagKey);
if (flagData === null) {
const result = await db.query(
'SELECT is_enabled, rollout_pct FROM feature_flags WHERE flag_key = $1',
[flagKey]
);
if (result.rows.length === 0) return false;
flagData = result.rows[0];
await redis.setex(`flag:${flagKey}`, FLAG_TTL, JSON.stringify(flagData));
}
const { is_enabled, rollout_pct } = flagData;
if (!is_enabled) return false;
if (rollout_pct >= 100) return true;
return stableHash(`${flagKey}:${userId ?? 'anon'}`) < rollout_pct;
} catch {
return false;
}
}
The 30-second TTL means flag changes propagate within 30 seconds without manual cache invalidation. For flags where you need instant propagation -- for example, disabling a feature in response to a production incident -- call redis.del(flag:${flagKey}) when you update the row.
Step 4: Add the API Endpoint
Expose flag state to the client over a simple HTTP endpoint:
// routes/flags.js (Express)
const { isEnabled } = require('../lib/featureFlags');
router.get('/api/flags/:key', async (req, res) => {
const { key } = req.params;
const userId = req.user?.id ?? null;
const enabled = await isEnabled(key, userId);
res.json({ key, enabled });
});
Keep this endpoint fast and lightweight. Do not return all flags in one call unless you batch-evaluate them server-side during the initial page render -- fetching individual flags from the browser adds round-trip latency.
Step 5: Use the Flag in Your Application
// routes/dashboard.js
const { isEnabled } = require('../lib/featureFlags');
router.get('/dashboard', async (req, res) => {
const showNewDashboard = await isEnabled('new_dashboard', req.user.id);
res.render('dashboard', { showNewDashboard });
});
For a React frontend:
// hooks/useFeatureFlag.js
import { useState, useEffect } from 'react';
export function useFeatureFlag(flagKey) {
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch(`/api/flags/${flagKey}`)
.then(res => res.json())
.then(data => { if (!cancelled) { setEnabled(data.enabled); setLoading(false); } })
.catch(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [flagKey]);
return { enabled, loading };
}
Step 6: Create and Manage Flags
Add a flag via SQL:
-- Enable new dashboard for 10% of users
INSERT INTO feature_flags (flag_key, is_enabled, rollout_pct, description)
VALUES ('new_dashboard', true, 10, 'Redesigned dashboard - 10% rollout');
Increase to 50 percent after validating:
UPDATE feature_flags
SET rollout_pct = 50, updated_at = NOW()
WHERE flag_key = 'new_dashboard';
Enable for all users:
UPDATE feature_flags
SET rollout_pct = 100, updated_at = NOW()
WHERE flag_key = 'new_dashboard';
Remove the flag from code and database when shipping is complete:
DELETE FROM feature_flags WHERE flag_key = 'new_dashboard';
Step 7: Testing Feature Flag Behavior
Feature flags introduce branching into your application logic, and both branches deserve test coverage. Write tests that exercise both the enabled and disabled code paths.
For the evaluator itself:
// test/featureFlags.test.js
const { isEnabled } = require('../lib/featureFlags');
// Mock the database to return controlled values
jest.mock('../lib/db', () => ({
query: jest.fn()
}));
const db = require('../lib/db');
test('returns false when flag is disabled', async () => {
db.query.mockResolvedValue({ rows: [{ is_enabled: false, rollout_pct: 100 }] });
expect(await isEnabled('test_flag', 'user123')).toBe(false);
});
test('returns true when flag is enabled at 100%', async () => {
db.query.mockResolvedValue({ rows: [{ is_enabled: true, rollout_pct: 100 }] });
expect(await isEnabled('test_flag', 'user123')).toBe(true);
});
test('is deterministic for partial rollout', async () => {
db.query.mockResolvedValue({ rows: [{ is_enabled: true, rollout_pct: 50 }] });
const result1 = await isEnabled('test_flag', 'user123');
const result2 = await isEnabled('test_flag', 'user123');
expect(result1).toBe(result2); // same user always gets same result
});
For application code that uses flags, inject the isEnabled function as a dependency rather than importing it directly. This makes it easy to stub in tests:
// routes/dashboard.js
async function dashboardHandler(req, res, flagFn = isEnabled) {
const showNewDashboard = await flagFn('new_dashboard', req.user.id);
res.render('dashboard', { showNewDashboard });
}
In tests, pass a stub for flagFn that returns true or false without touching the database. This pattern works regardless of whether you are using a custom evaluator or a managed SDK from LaunchDarkly or Flagsmith.
Additional Resources
For the architectural context behind this implementation including the flag lifecycle practices that prevent long-term technical debt, the full feature flag guide from 137Foundry's app development team covers the decision framework and operational conventions.
For teams evaluating managed platforms once this approach scales to its limits, LaunchDarkly is the market-leading commercial option. Flagsmith is open-source and can be self-hosted, which is worth evaluating before committing to a commercial subscription.
The PostgreSQL documentation and Redis documentation are the primary references for the persistence and caching layers used in this guide.
Top comments (0)