← Complete Projects

阅读中文版本

Build a Feature Flag Service with Targeting Rules

A complete feature flag service: typed flags, percentage rollout, user targeting, kill switches, cached evaluation, and deterministic bucketing tests.

intermediate~4 hfeature-flagsnodepostgresqlrolloutservice

Project goal

Evaluate feature flags by user with deterministic percentage rollouts and targeting rules, and let operators disable any flag instantly.

Final features

  • Flag model
  • Deterministic bucketing
  • Rule evaluation
  • Local cache
  • Audit and kill switch

Architecture

Flags are typed configurations stored in the database and cached in memory with a version. Evaluation hashes a stable user key into a 0-99 bucket for percentage rules, then applies explicit allow/deny lists and a global kill switch.

Prerequisites

  • Node.js 20 LTS or later
  • PostgreSQL 15+ or a config file for local runs
  • An application that can call the evaluate API

Dependencies

  • pg ^8.12.0 — Flag configuration storage
  • express ^4.21.0 — Evaluation and admin endpoints

Directory structure

feature-flag-service/
├── src/evaluate.ts
├── src/bucketing.ts
├── src/rules.ts
├── src/cache.ts
├── tests/evaluate.test.ts
└── package.json

Phases

1. Flag model

Define flags with a type, default, rules, and a rollout percentage.

type Flag = { key: string; type: 'boolean'|'string'; default: unknown; rollout: number; rules: Rule[] };

Expected behavior: A typed model prevents string-versus-boolean surprises at call sites.

2. Deterministic bucketing

Hash flag key plus user key into a stable bucket.

function bucket(flagKey, userKey) {
  const h = createHash('sha1').update(flagKey + ':' + userKey).digest();
  return h.readUInt32BE(0) % 100;
}

Expected behavior: The same user must always fall on the same side of a rollout.

3. Rule evaluation

Apply ordered targeting rules before the percentage rollout.

for (const rule of flag.rules) if (matches(rule, ctx)) return rule.value;

Expected behavior: Explicit targeting overrides a percentage so QA users can opt in.

4. Local cache

Cache the flag set by version and refresh on a short interval.

if (cache.version !== currentVersion) await refresh();

Expected behavior: Evaluation must be fast and must not hit the database per request.

5. Audit and kill switch

Record who changed a flag and allow an immediate global off.

if (flag.killed) return flag.default;

Expected behavior: Incidents need a one-step switch that bypasses targeting.

Testing

Assert that bucketing is stable across calls, that a rollout percentage converges to roughly the configured share over many users, and that a kill switch overrides all rules.

Troubleshooting

A rollout flips users back and forth

The bucket included a mutable field or a random value; hash only stable identifiers.

Evaluation is slow

The cache refresh happens per request instead of on an interval.

Deployment

Serve evaluation from an in-memory cache refreshed from the database. Push changes through a versioned configuration and keep the last good version so a bad write does not break evaluation.

Known limitations

  • Bucketing is per user key and not sticky across keys.
  • No streaming updates; evaluation refreshes on an interval.
  • The example does not implement A/B metric analysis.

Project overview

A feature flag is a permission slip for code that is already deployed, and the only safe version is one that can be turned off in seconds. This build evaluates flags per user with deterministic rollouts and an instant global kill switch.

You will store flags as typed configuration, cache them in memory with a version, hash a stable user key into a bucket for percentage rollouts, apply explicit allow and deny lists, and let an operator disable any flag immediately.

Determinism is the property that makes a rollout sane: the same user must land in the same bucket across every call and every instance, or a user sees a feature flicker on and off as requests land on different servers.

Evaluation happens in-process from a cache, so a flag check costs no network call in the request path. The cache refresh is the only place that talks to the store, which keeps the hot path predictable.

Each phase adds one piece: the flag model, bucketing, rule evaluation, the cache, and the kill switch, so the service is usable after any of them.

The project is for teams that have shipped a feature behind an if-statement and then wanted to turn it off at two in the morning. It makes that switch a piece of configuration with a clear owner rather than a code deploy, and it makes the result deterministic for any given user.

What you will build

The service provides five capabilities, each with a test that evaluates flags:

  • Flag model
  • Deterministic bucketing
  • Rule evaluation
  • Local cache
  • Audit and kill switch

A typed flag model describes the flag; deterministic bucketing assigns a stable percentage rollout; rule evaluation applies allow and deny lists; a local cache serves evaluation without a network call; and a kill switch overrides every rule.

The design favors a stable hash over random assignment, because a rollout that reshuffles users on each evaluation is not a rollout at all. Stability is a requirement rather than an optimization.

Architecture and data model

Flags are typed configurations stored in the database and cached in memory with a version. Evaluation hashes a stable user key into a 0-99 bucket for percentage rules, then applies explicit allow/deny lists and a global kill switch.

A flag is a typed configuration: a key, a state, a rollout percentage, and optional allow and deny lists. Evaluation hashes the user key into a bucket from zero to ninety-nine and compares it to the rollout.

The cache holds the whole flag set with a version and is refreshed from the store; evaluation reads the cache only, so the request path never blocks on a database. A version bump is how a change propagates.

The kill switch is a flag state that short-circuits evaluation, and it must be reachable even if the store is degraded. Keeping the last known good configuration means a bad write cannot take evaluation down with it.

Hashing the user key rather than randomizing means a user’s bucket is fixed for the flag, so a rollout is monotonic and increasing the percentage only ever adds users rather than moving them.

The deny list is checked before the allow list so an operator can always block a user or a bad deployment even if a broader rule would otherwise include them. Nothing about the bucket is stored, which is what keeps the service small and makes a rollout reproducible from the user key alone.

Prerequisites and dependencies

You need a database for flag definitions and a mechanism to push a version change to the evaluating services.

  • Node.js 20 LTS or later
  • PostgreSQL 15+ or a config file for local runs
  • An application that can call the evaluate API

The dependencies keep the store, the cache, and the evaluation path in one deployable service:

  • pg (^8.12.0) — Flag configuration storage
  • express (^4.21.0) — Evaluation and admin endpoints
pnpm init
pnpm add pg express
pnpm add -D typescript tsx vitest

Fix the hash function and its salt in configuration and treat a change to either as a migration, because changing the hash reassigns every user and can turn a rollout into a surprise.

Directory structure

Separate the flag model, bucketing, rule evaluation, the cache, and the kill switch so each can be tested with fixed inputs:

feature-flag-service/
├── src/evaluate.ts
├── src/bucketing.ts
├── src/rules.ts
├── src/cache.ts
├── tests/evaluate.test.ts
└── package.json

Phase 1: Flag model

Define flags with a type, default, rules, and a rollout percentage.

type Flag = { key: string; type: 'boolean'|'string'; default: unknown; rollout: number; rules: Rule[] };

A typed model prevents string-versus-boolean surprises at call sites.

Model a flag with a type and explicit states rather than a free-form value, so a boolean flag cannot be accidentally set to a string. Typed configuration is what makes a flag safe to evaluate anywhere.

Phase 2: Deterministic bucketing

Hash flag key plus user key into a stable bucket.

function bucket(flagKey, userKey) {
  const h = createHash('sha1').update(flagKey + ':' + userKey).digest();
  return h.readUInt32BE(0) % 100;
}

The same user must always fall on the same side of a rollout.

Hash a stable identifier into a bucket and compare it to the rollout. The hash must be fixed and salted deliberately, because a change to either silently reassigns users and invalidates the meaning of a percentage.

Phase 3: Rule evaluation

Apply ordered targeting rules before the percentage rollout.

for (const rule of flag.rules) if (matches(rule, ctx)) return rule.value;

Explicit targeting overrides a percentage so QA users can opt in.

Evaluate rules in a defined order: kill switch, then deny list, then allow list, then percentage. An explicit order prevents the bug where a soft rule overrides a hard one.

Phase 4: Local cache

Cache the flag set by version and refresh on a short interval.

if (cache.version !== currentVersion) await refresh();

Evaluation must be fast and must not hit the database per request.

Serve evaluation from an in-memory cache refreshed from the store, and keep the last good version. A cache that fails open to defaults is how a flag change becomes a silent outage.

Phase 5: Audit and kill switch

Record who changed a flag and allow an immediate global off.

if (flag.killed) return flag.default;

Incidents need a one-step switch that bypasses targeting.

Make the kill switch a first-class state that overrides everything, and audit every change with an actor and a timestamp. The ability to disable a flag instantly is the feature; the audit is what makes it trustworthy.

Testing

Assert that bucketing is stable across calls, that a rollout percentage converges to roughly the configured share over many users, and that a kill switch overrides all rules.

Assert that bucketing is stable across repeated calls and across a simulated restart, since determinism is the property everything else assumes.

Assert that a rollout percentage converges to roughly the configured share over many users, which catches a hash with poor distribution.

Assert that a kill switch overrides allow lists and percentage, and that a deny list beats an allow list, because rule precedence is where flag bugs hide.

Assert that evaluation still works with the store unreachable, served from the last good cache, which is the availability guarantee the cache exists to provide.

Assert that two independent evaluation instances agree on every bucket for a large user set, since cross-instance disagreement is the bug that makes a rollout flicker, and assert that raising a rollout percentage never removes a user who was already included.

pnpm vitest run

Deployment and operations

Serve evaluation from an in-memory cache refreshed from the database. Push changes through a versioned configuration and keep the last good version so a bad write does not break evaluation.

Serve evaluation from an in-process cache refreshed from the database, and push changes through a versioned configuration. Never evaluate against the database directly in the request path.

Keep the last good configuration so a bad write does not break evaluation, and alert when a refresh fails, since a stale cache is usually invisible until a flag fails to take effect.

Roll out flag changes the same way you roll out code: gradually, with a way to revert. A flag is production configuration and deserves the same review as a deploy.

Record an audit entry for every change with the actor and the previous value, so a surprise rollout can be traced to the change that caused it.

Load the flag set at process start and refresh on a version change, so a cold start has a full configuration and does not evaluate against defaults for the first seconds, and version the exported configuration so a client can tell whether it is stale.

Security considerations

Restrict the admin endpoints to authorized operators and audit every change. Never expose the full flag configuration to untrusted clients.

Authorize flag changes as a privileged operation, because flipping a kill switch or a rollout is a production change with production consequences.

Treat the flag payload as configuration rather than a place for secrets, since it is cached broadly and often logged during debugging.

Keep the evaluation API read-only and cheap, so it cannot be used as a write path or an amplification vector against the store.

Validate the targeting lists on write, because a malformed rule that silently matches nothing is the same as a flag that never turns on.

Do not let the evaluation endpoint accept an operator-supplied override in production, because a query parameter that forces a flag on becomes a way to probe unreleased behavior.

Observability

Track evaluation latency, cache refresh age, and per-flag exposure counts. Alert when a flag returns one value for every user, which often means a rule matched unconditionally.

Track evaluation latency, because flags are called in hot paths and a slow evaluation multiplies across every request.

Chart flag change frequency and the number of active flags, since flag debt is real and a set that never shrinks becomes configuration nobody understands.

Alert on cache refresh failures, because a stale cache makes flag changes appear to have no effect and leads operators to re-apply them repeatedly.

Log the evaluated bucket alongside the result at debug level, so a rollout dispute can be settled with the actual assignment rather than a recount.

Track the number of flags evaluated per request and the total evaluation time, because a handler that checks dozens of flags quietly accumulates latency, and chart evaluation errors separately from results so a fallback does not look like a deliberate off.

Expose the cache age so an operator can see how long ago the configuration was refreshed, because an old cache explains most surprises that look like the service ignoring a change.

Troubleshooting

A rollout flips users back and forth. The bucket included a mutable field or a random value; hash only stable identifiers.

Evaluation is slow. The cache refresh happens per request instead of on an interval.

If a user flips between on and off, check that the hash input is stable; an identifier that changes per request produces exactly that symptom.

If a flag change has no effect, check the cache version before the rule, because the change may be stored but not yet propagated to the evaluating instances.

If the same user sees different results on two requests, check that both requests carry the same identifier and that both instances run the same flag version.

Limitations and next steps

  • Bucketing is per user key and not sticky across keys.
  • No streaming updates; evaluation refreshes on an interval.
  • The example does not implement A/B metric analysis.

Rollouts are a single percentage per flag; there is no multivariate experiment framework or per-segment percentage, which a larger system would need.

Evaluation targets a user key only; targeting by attributes such as plan or region would require a rule DSL this build does not include.

The cache refreshes on a version change rather than streaming updates, so propagation latency is bounded by the refresh interval.

There is no built-in expiry for flags, so retiring a flag is a manual cleanup step that the change audit supports but does not enforce.

There is no per-language SDK and no streaming update protocol; consumers call the HTTP endpoint or share the cache format. Flags also carry no scheduling, so a time-boxed rollout is a manual step, though an expiry field would make temporary flags self-cleaning.

Flags are defined in the store but not grouped or namespaced, so a large flag set becomes hard to navigate, and the service offers no dependency ordering between related flags.