DEV Community

137Foundry
137Foundry

Posted on

How to Implement Feature Flags in a Node.js Web Application

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);
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

Increase to 50 percent after validating:

UPDATE feature_flags
SET rollout_pct = 50, updated_at = NOW()
WHERE flag_key = 'new_dashboard';
Enter fullscreen mode Exit fullscreen mode

Enable for all users:

UPDATE feature_flags
SET rollout_pct = 100, updated_at = NOW()
WHERE flag_key = 'new_dashboard';
Enter fullscreen mode Exit fullscreen mode

Remove the flag from code and database when shipping is complete:

DELETE FROM feature_flags WHERE flag_key = 'new_dashboard';
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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)