Feature flags are one of those tools that feel magical until you build one yourself. Then they feel obvious — and you wonder why you were paying $400 a month for something that is, at its core, a conditional wrapped around a database lookup.

This article walks through building a lightweight, production-worthy feature flag system in Node.js. No third-party SDK required. By the end, you will understand the architecture decisions that every SaaS flag tool makes behind the scenes — and you will be able to make those decisions for your own product.

What a Feature Flag Actually Is

A feature flag is a runtime decision: should this user, in this context, see or execute this code path? That decision is made without a deployment. The code for both branches ships together; the flag controls which branch runs.

This enables progressive delivery — releasing features to 1% of users, then 10%, then everyone, with the ability to reverse instantly. It also enables kill switches (turn off a broken feature without a rollback), beta access (give early users new UI), and A/B testing hooks.

The mechanics are not complicated. The implementation details are where most teams get it wrong.

The Core Data Model

Start with a feature_flags table. Keep it simple but expressive:

CREATE TABLE feature_flags (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  key         VARCHAR(100) UNIQUE NOT NULL,   -- e.g. "new_checkout_flow"
  enabled     BOOLEAN NOT NULL DEFAULT FALSE, -- global kill switch
  rollout_pct SMALLINT NOT NULL DEFAULT 0,    -- 0–100
  rules       JSONB,                          -- targeting rules
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  updated_at  TIMESTAMPTZ DEFAULT NOW()
);

Three concepts live in this schema:

  • enabled — the kill switch. If false, the flag is off for everyone regardless of anything else.
  • rollout_pct — percentage-based rollout. A value of 20 means roughly 20% of users see the feature.
  • rules — a JSONB column holding structured targeting rules (more on this below).

The Evaluation Engine

The flag evaluator is a pure function. It takes a flag definition and a user context, and returns a boolean. Here is the Node.js implementation:

const crypto = require("crypto");

function evaluateFlag(flag, userContext) {
  // 1. Kill switch check
  if (!flag.enabled) return false;

  // 2. Targeting rules (evaluated before percentage rollout)
  if (flag.rules && flag.rules.length > 0) {
    const matched = flag.rules.some((rule) => matchesRule(rule, userContext));
    if (matched) return true;
  }

  // 3. Percentage rollout via deterministic hashing
  if (flag.rollout_pct > 0) {
    const hash = crypto
      .createHash("sha256")
      .update(`${flag.key}:${userContext.userId}`)
      .digest("hex");
    const bucket = (parseInt(hash.slice(0, 8), 16) % 100) + 1;
    return bucket <= flag.rollout_pct;
  }

  return false;
}

function matchesRule(rule, userContext) {
  const value = userContext[rule.attribute];
  if (rule.operator === "eq") return value === rule.value;
  if (rule.operator === "in") return rule.value.includes(value);
  if (rule.operator === "gte") return Number(value) >= Number(rule.value);
  return false;
}

The hashing step deserves attention. By hashing flag.key + userId and bucketing the result into a 1–100 range, you get deterministic, sticky assignment — the same user always lands in the same bucket for a given flag. This is critical. Without it, a user would flip between feature states on every page load, which is both confusing and impossible to debug.

Targeting Rules in Practice

The rules JSONB field might look like this for a beta program:

[
  { "attribute": "plan", "operator": "in", "value": ["pro", "enterprise"] },
  { "attribute": "country", "operator": "eq", "value": "GH" }
]

This means: if the user is on a Pro or Enterprise plan, OR is in Ghana, they see the feature — regardless of the rollout percentage. Rules are your escape hatch for precision targeting before a broad rollout.

Keep rule evaluation fast. Rules are checked before the hash bucket calculation, so a rule match short-circuits the rest. For most apps, this entire evaluation runs in under a millisecond.

Caching: The Part Everyone Underestimates

Reading from the database on every request is not acceptable at scale. A flag evaluation happening inside an API endpoint that handles 5,000 requests per second means 5,000 DB queries per second — per flag.

The solution is a short-lived in-process cache with a TTL of 15–30 seconds. Load all flags into memory on startup and refresh them on a background interval. A simple Map works fine:

let flagCache = new Map();

async function refreshFlags(db) {
  const flags = await db.query("SELECT * FROM feature_flags");
  const newCache = new Map();
  flags.rows.forEach((f) => newCache.set(f.key, f));
  flagCache = newCache;
}

// Refresh every 20 seconds
setInterval(() => refreshFlags(db), 20_000);

This means flag changes propagate within 20 seconds — acceptable for most use cases. If you need sub-second propagation, layer in a Redis pub/sub channel that triggers an immediate cache invalidation when a flag record is updated.

The Admin Interface

A flag system without an admin UI is a flag system no one will use. At minimum, expose REST endpoints to:

  • List all flags with their current state
  • Toggle enabled on/off (the kill switch)
  • Update rollout_pct
  • Create and delete rules

Protect these endpoints with role-based access. The ability to toggle a feature flag in production is a form of deployment — treat it with the same access controls.

Architecture Decisions Worth Making Deliberately

Per-environment isolation

Flags in development should be independent from production. Store flags per environment, either with a separate table per environment or an environment column. Never share flag state across environments.

Audit logging

Every flag change should write a record: who changed what, when, and what the previous value was. This is invaluable when debugging a production incident where someone quietly toggled a flag four hours ago.

Default values on SDK failure

If your flag evaluation throws (DB unreachable, cache stale), it should fail safe. Default to false — feature off — not an exception that crashes the request handler. Wrap every evaluation in a try/catch with a fallback.

When to Use a Third-Party Tool Instead

Building your own flag system makes sense when you want control, have the engineering bandwidth, and are not paying for features you will never use. It stops making sense when you need multivariate flags, complex experimentation analytics, SDKs for ten platforms, or compliance features like GDPR-safe user targeting. At that point, the build-vs-buy math shifts.

But even if you end up choosing a commercial tool, having built one yourself means you will configure it correctly, understand its failure modes, and not over-engineer your usage of it.

Why This Matters for Your Project

Progressive delivery is not a luxury for large engineering teams — it is a risk management strategy for any team shipping software to real users. A feature flag system, even a minimal one, lets you separate deployment from release, catch issues before they affect everyone, and move faster without the anxiety of big-bang launches. Whether you build it yourself or adopt a managed solution, understanding the mechanics underneath makes you a more deliberate architect.