ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeFeature Flags Without a $500/Month Platform: Build Your Own in Node.js

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

ZyVOP
ZyVOP
Senior Developer
May 26, 2026
7 min read
Feature Flags Without a $500/Month Platform: Build Your Own in Node.js
#LaunchDarkly alternative self hosted#build feature flags Postgres#feature flag targeting 2026#feature toggles TypeScript#percentage rollout Node.js#feature flag Redis cache#feature flags Node.js

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.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1