DEV Community

leiferiksson8493
leiferiksson8493

Posted on

Node.js Backend API Feature Flags for Property Pricing Rollout (with Stable Cohorts)

Short answer: for a property-management SaaS, put feature flags in the Node.js backend API, use stable account targeting for the percentage rollout, and let React display the result rather than decide the price.

This setup needs two properties before it needs a dashboard: the same account must keep getting the same rule, and every price decision must leave a small, useful trail. I would choose deterministic account targeting plus structured decision events. Random sampling is shorter to code, but it creates noisy comparisons and can show a property manager different prices across requests.

Choice Cohort stability Signal quality Operational load
Deterministic account bucket Stable for a fixed key and salt High Low
Random choice per request Unstable Poor Low
Stored allowlist only Stable High, but narrow Medium

Recommendation: evaluate the flag in the Node.js backend, bucket on an immutable tenant or account ID, return the computed price to React, and emit one bounded event for the decision. Start with an internal allowlist, then move through explicit percentages. Keep a kill switch.

This is the revenue-per-hour choice. It is small enough to ship this week, yet it preserves the evidence needed to decide whether the new rule should advance.

How should a Node.js backend API roll out feature flags?

React can display the flag state, but it should not decide which rent, fee, or discount applies. A browser value can be stale, manipulated, or evaluated with a different identity from the API request. The backend already has the authenticated account and the inputs used to calculate the price, so it can make one authoritative decision.

Price once.

The response can include a harmless label such as pricingRule: "candidate" when the interface needs to explain a preview. The client still renders the server's amount. It never recalculates the business rule. This keeps invoices, previews, and background jobs aligned.

Feature flags also create carrying cost. Martin Fowler distinguishes release toggles from longer-lived categories and warns that toggle configuration becomes part of the system's complexity. For a solo operator, each temporary rollout flag therefore needs an owner, an expiry condition, and a deletion task attached to the release. Ship weekly. Remove the branch after the decision.

The two criteria that matter

The first is identity stability. A property manager may belong to one company, manage dozens of buildings, and retry the same request. Bucketing by request ID or session would contaminate the comparison. Bucket by the smallest stable business entity that must receive consistent pricing. In this case, that is accountId; if contracts are negotiated per organization, use organizationId instead. Never bucket on an email address that can change.

The second is signal quality. Log a decision event only when code actually evaluates the rule, and keep its dimensions bounded: flag key, variant, rule version, bucket, and request correlation ID. Do not attach street addresses, tenant names, raw emails, or the full request body. Those fields increase privacy exposure and turn cardinality into an operations bill.

Count outcomes separately from decisions. A decision counter answers, "How much traffic saw each rule?" A pricing validation counter answers, "Did either branch reject more inputs?" A business metric such as accepted quotes may be joined later at the account and time-window level, subject to the product's privacy rules. Mixing all three into one success metric makes a quiet rollout look healthy even when the evaluator is failing closed.

Small logs win.

A stable TypeScript evaluator for Express

Node's createHash gives the evaluator a deterministic digest. The code below converts the first 32 digest bits into one of 10,000 buckets, which permits percentage steps to two decimal places without storing an assignment table. The salt includes a version so a future rollout can deliberately reshuffle cohorts; changing it during an active experiment would invalidate comparisons.

import { createHash } from "node:crypto";
import type { Request, Response } from "express";

type PricingContext = {
  accountId: string;
  internalAccount: boolean;
};

type Rollout = {
  enabled: boolean;
  basisPoints: number;
  salt: string;
};

function bucket(accountId: string, salt: string): number {
  const digest = createHash("sha256")
    .update(`${salt}:${accountId}`)
    .digest();

  return digest.readUInt32BE(0) % 10_000;
}

function useCandidateRule(ctx: PricingContext, rollout: Rollout): boolean {
  if (!rollout.enabled) return false;
  if (ctx.internalAccount) return true;

  const threshold = Math.max(0, Math.min(10_000, rollout.basisPoints));
  return bucket(ctx.accountId, rollout.salt) < threshold;
}

export function quotePrice(req: Request, res: Response): void {
  const ctx: PricingContext = {
    accountId: req.auth.accountId,
    internalAccount: req.auth.internalAccount
  };
  const rollout: Rollout = req.app.locals.pricingRollout;
  const candidate = useCandidateRule(ctx, rollout);
  const ruleVersion = candidate ? "candidate-v2" : "control-v1";

  req.app.locals.telemetry.record("pricing_rule_decision", {
    flagKey: "pricing-rule-v2",
    variant: candidate ? "candidate" : "control",
    ruleVersion,
    bucket: bucket(ctx.accountId, rollout.salt),
    requestId: req.id
  });

  const amountCents = calculatePrice(req.body, ruleVersion);
  res.json({ amountCents, pricingRule: candidate ? "candidate" : "control" });
}
Enter fullscreen mode Exit fullscreen mode

The example assumes authentication middleware supplies req.auth and a request ID. It also assumes rollout configuration has already been validated at startup. In production, define those Express types and reject malformed configuration before serving traffic; silently treating NaN as a percentage is the kind of tiny mistake that burns an afternoon.

Do not use the event name as an unbounded label. In OpenTelemetry, metric attributes become dimensions, while logs and traces can carry richer per-request context. Put requestId in a log or span rather than a metric label. The W3C Trace Context standard provides interoperable traceparent and tracestate headers when a request crosses services.

Rollout without manufacturing noise

Before any percentage rollout, test three boundaries: the disabled flag always selects control, the internal allowlist selects the candidate, and bucket values immediately below and at the threshold choose opposite variants. Add a property test asserting that one account and salt always produce one bucket. These tests are cheap. They protect the mechanism rather than one lucky set of IDs.

Deploy the evaluator with the flag disabled first. Then enable internal accounts, inspect pricing validation failures, and verify that decision volume matches eligible request volume within the known exclusions. Advance the percentage only after a full business cycle for the property workflow being measured. A lease quote may be created today and accepted days later, so five quiet minutes prove very little.

Do not reshuffle.

Use a short operating note for each step:

  1. Record the rule version, salt, eligibility rule, percentage, and change time.
  2. Compare candidate and control on technical errors and pricing invariants before business outcomes.
  3. Stop on a predefined invariant breach; disabling the flag must restore the control rule without a deployment.
  4. Hold the cohort stable while observing it. Changing the salt and percentage together destroys the baseline.

This is where observability earns its keep. The goal is not maximum telemetry. It is enough evidence to answer who received the candidate rule, which rule ran, and whether the result violated a known invariant.

When the runner-up is better

A limitation of deterministic percentage targeting is that a stable hash understands identity, not business eligibility. The trade-off is less operational state in exchange for less control over individual assignments. This approach does not fit a rollout that must honor contracts, jurisdictions, or named pilot customers.

A stored allowlist beats percentage bucketing when the rollout must follow contracts, jurisdictions, or named pilot customers. Property pricing can depend on local regulation, and a hash knows nothing about legal eligibility. Filter for eligibility first, then bucket only within the eligible population.

An assignment table is also better when analysts require a frozen experimental cohort across multiple unrelated flags, or when support must move one account between variants with an audit record. It adds a database read, migration work, and a cleanup path. I would pay that cost only when the business requirement needs explicit assignment; outsourcing an ordinary deterministic split to a tiny pure function leaves more hours for product work.

Per-request randomness has one narrow use: sampling telemetry after the pricing decision. It should not choose the price itself. Those are different jobs.

The practical finish line is plain: the backend owns the rule, stable business identity owns the cohort, and bounded decision events explain the rollout. Once the candidate becomes the default, delete the old branch, its flag configuration, and any temporary rollout-only telemetry. A permanent flag is permanent surface area.

Sources

Top comments (0)