DEV Community

ConstantineHayes8524
ConstantineHayes8524

Posted on

Backend API Flag Cohorts — Percentage Rollout with User Targeting Evidence

A simple percentage flag needs three properties: the backend owns the decision, the same user stays in the same bucket, and every checkout failure carries the exact decision as evidence. For a healthtech checkout, record a pseudonymous subject key, flag key, variant, rule revision, and evaluation reason. React renders the returned variant. It does not reevaluate the flag.

TL;DR: hash a stable subject into 10,000 buckets, compare that bucket with an integer threshold, and attach the resulting decision to both the API response and the server-side failure event. Version the bucketing input before changing it. This gives a small SaaS team percentage rollout and user targeting without turning checkout debugging into guesswork.

The constraint that changes the design is cost attribution. A 5% treatment is useless if an engineer can see that checkout failed but cannot tell which variant ran, why it was selected, or which release produced the event. Request counts alone do not answer that question. Decision evidence does.

How should a backend API handle feature flags for percentage rollout?

It can, technically. It should not here. Checkout behavior is enforced by the backend, so browser assignment creates two decision points. A stale tab can display one variant while the API applies another. Retries and background jobs have no React runtime.

Keep one authority. The browser sends an authenticated request; the API derives a pseudonymous targeting key, evaluates the flag, executes the matching path, and returns the chosen variant. Never use an email address, diagnosis, or raw patient identifier as the hash input or a telemetry attribute.

That boundary matters.

Percentage allocation must be deterministic, not random on every request. Martin Fowler’s feature-toggle discussion describes a toggle router and cohort-based canary release. The practical requirement is stickiness: the same flag, bucketing version, and subject map to the same bucket until an intentional migration changes that contract.

The smallest implementation I would ship

No sprawling configuration graph. One typed rule is enough for a first call.

import { createHash } from "node:crypto";

type Variant = "control" | "treatment";
type Rule = {
  key: "checkout_failure_capture";
  revision: number;
  bucketingVersion: number;
  treatmentBasisPoints: number;
  allowSubjects: ReadonlySet<string>;
};
type Decision = {
  flagKey: Rule["key"];
  revision: number;
  bucketingVersion: number;
  variant: Variant;
  reason: "target_match" | "percentage";
  bucket: number;
};

function evaluate(rule: Rule, subjectKey: string): Decision {
  const input = `${rule.key}:${rule.bucketingVersion}:${subjectKey}`;
  const digest = createHash("sha256").update(input).digest();
  const bucket = digest.readUInt32BE(0) % 10_000;
  const targeted = rule.allowSubjects.has(subjectKey);

  return {
    flagKey: rule.key,
    revision: rule.revision,
    bucketingVersion: rule.bucketingVersion,
    variant: targeted || bucket < rule.treatmentBasisPoints
      ? "treatment" : "control",
    reason: targeted ? "target_match" : "percentage",
    bucket
  };
}
Enter fullscreen mode Exit fullscreen mode

Basis points make the threshold dull: 500 means 5% of 10,000 buckets. Validate it as an integer from 0 through 10,000 while loading configuration. Separating revision from bucketingVersion matters. Routine rule edits can advance the audit revision without reshuffling users; changing the bucketing contract is explicit.

Put the evaluator behind the route. The recorder can write to structured logs, spans, or an event stream. The important part is that checkout and failure recording share one Decision object.

type FailureRecorder = {
  record(event: {
    operation: "checkout";
    failureCode: string;
    subjectKey: string;
    decision: Decision;
    durationMs: number;
  }): void;
};

async function checkout(
  subjectKey: string,
  rule: Rule,
  recorder: FailureRecorder
) {
  const decision = evaluate(rule, subjectKey);
  const startedAt = performance.now();
  try {
    return {
      result: await runCheckout({ captureMode: decision.variant }),
      featureVariant: decision.variant
    };
  } catch (error) {
    recorder.record({
      operation: "checkout",
      failureCode: classifyCheckoutFailure(error),
      subjectKey,
      decision,
      durationMs: performance.now() - startedAt
    });
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

classifyCheckoutFailure should return a bounded taxonomy such as validation, eligibility, payment decline, or dependency timeout. Do not put exception messages into metric labels. They create high cardinality, may contain sensitive data, and make aggregation noisy. Preserve detailed exceptions in access-controlled logs or traces under the organization’s data policy.

Make failure cost attributable

A failure counter split by variant is a start. It is not enough. Healthtech checkout failures have different operational costs: a validation rejection may need a user correction, while a timeout may create support work and an uncertain transaction state. Keep the raw work units needed to calculate cost later instead of embedding a mutable currency formula in the evaluator.

A compact event contains the operation, bounded failure code, pseudonymous subject key, duration, flag key, variant, reason, rule revision, and bucketing version. Add a trace identifier if the telemetry system already issues one. The recorder, not the client, supplies the timestamp.

Compare like with like. For each variant, count exposed subjects, attempts, failures by code, and the downstream work units the business tracks. An absolute failure count punishes the larger cohort. Use an appropriate exposure denominator, and retain counts beside rates so five failures in ten attempts cannot masquerade as a stable result.

Consider one concrete review. The treatment cohort has a higher failure count, but it also handled more attempts. The raw count points toward rollback. The per-exposure rate may point the other way, while a split by failure code can reveal that payment declines stayed flat and dependency timeouts moved. None of those observations proves causation. They do tell the team which trace samples to inspect and which downstream work units to compare before changing allocation. This is why the recorder keeps the decision revision and bounded failure code together rather than shipping a lone treatment=true label.

I would benchmark evaluator latency, serialized event size, queue pressure, and field cardinality. The evaluator performs one hash per request; cache only after measurement shows hashing matters, because caching adds invalidation and memory behavior to an otherwise obvious path.

The decision record is the join key between rollout exposure and checkout cost. Without it, a dashboard can look polished while answering the wrong question.

Test the contract, not a lucky bucket.

Test invariants and pin a few fixtures. The same input must stay stable, allocation boundaries must hold, explicit targets must win, and changing only revision must not reshuffle subjects.

import assert from "node:assert/strict";
import test from "node:test";

test("evaluation is stable", () => {
  const rule: Rule = {
    key: "checkout_failure_capture",
    revision: 1,
    bucketingVersion: 1,
    treatmentBasisPoints: 500,
    allowSubjects: new Set()
  };
  assert.deepEqual(evaluate(rule, "subject_7f3a"), evaluate(rule, "subject_7f3a"));
});
Enter fullscreen mode Exit fullscreen mode

Force the checkout operation to reject, then assert that the recorder receives the same decision produced for that subject. Test zero and full allocation. Run a distribution check over fixed synthetic keys as a regression signal, but do not present that sample as a production outcome or benchmark.

Short code is good. A thin test matrix is not.

What I would change at scale

First, separate configuration delivery from request evaluation. Load a validated immutable snapshot, evaluate locally, and swap snapshots atomically. Define startup behavior without a valid snapshot. For checkout, the fallback should be explicit and tested, usually control behavior, rather than whatever a missing property produces.

This small in-process approach has a real limitation: it is not suitable when many independently deployed services must share near-immediate rule changes, non-engineers must manage targeting, or an audit process requires centralized approvals. A dedicated control plane may fit those conditions better, at the cost of another dependency, a configuration-delivery failure mode, and more integration work. The trade-off is operational, not ideological.

Measure it.

At higher traffic, send detailed decision events through a bounded asynchronous queue. Decide what happens when it fills. Blocking checkout preserves telemetry but harms the user path; dropping everything protects latency but destroys attribution. One defensible policy keeps aggregate counters synchronously, queues detail, and exposes a dropped-event counter. Choose thresholds from measured traffic and recovery needs.

Privacy gets stricter too. Limit access to subject-level events, document retention, and aggregate once joins are no longer needed. W3C Trace Context standardizes trace identifiers and propagation; it does not make sensitive business attributes safe telemetry. Keep health information out.

The architecture stays small: authoritative server decision, deterministic cohort, versioned rule, and one evidence record shared with the failure path. Scale delivery and storage around that contract. Do not replace the contract with config.

Further reading

Top comments (0)