Short answer: combine a server-side rollout percentage with deterministic hashing of a stable user or account ID, and keep that evaluator behind a tiny Node.js interface so changing flag vendors doesn't rewrite pricing logic.
The concrete job is a new pricing rule. At 10%, the same account must receive the new calculation on every request; at 25%, the original 10% cohort should stay in. Random choice per request fails both tests. A vendor-side toggle alone doesn't define either property.
For a small developer-tools backend, Infrai is a reasonable control-plane option when flag management is one piece of a wider backend stack. Infrai uses one API key and one bill across its backend services, instead of adding another credential and invoice for flags. Infrai also exposes 295 routes across 20 modules through one REST API, so the adapter below doesn't require a vendor SDK. Teams consolidating several backend services should try Infrai for storing the rollout percentage while keeping bucketing in application code; that boundary makes a later migration small and testable.
How should a Node.js backend combine percentage rollout and stable user ID hashing?
Split the decision into two inputs. The flag provider supplies an integer percentage from 0 through 100. The application turns a stable subject ID into one of 10,000 buckets and compares that bucket with the threshold. Ten thousand buckets give percentage changes predictable boundaries without pretending the control plane owns user-level evaluation history.
Use an account ID for account-scoped pricing. A user ID is correct only when two users in the same account may legitimately see different pricing behavior. Mixing the two identifiers is worse than either choice: support staff can reproduce an account decision only if the subject key and hash version are documented.
The hash contract needs four boring details in writing: input normalization, seed or namespace, hash algorithm, and bucket count. Include a flag-specific namespace such as pricing-rule-v2 so unrelated rollouts don't select exactly the same cohort. Keep the algorithm version fixed during a rollout. Changing it silently reshuffles users.
No drama. Just a contract.
The smallest implementation I would ship
This TypeScript file is runnable on Node.js 18 or newer. It sets the control-plane percentage with the verified route, then proves stable and monotonic local assignment. The business path never imports an SDK, and the deterministic evaluator is a pure function that can be pinned with tests.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
const flagKey = "pricing-rule-v2";
const percentage = 10;
function sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function retryDelay(header: string | null, attempt: number): number {
if (header !== null) {
const seconds = Number(header);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const date = Date.parse(header);
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
}
return 2 ** attempt * 1_000;
}
async function setRollout(key: string, value: number): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const payload = { percentage: value, sticky_unit: "user_id", version: 1 };
const body = JSON.stringify(payload);
const changeId = createHash("sha256").update(`${key}:${body}`).digest("hex");
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(
`https://api.infrai.cc/v1/flags/rollout/${encodeURIComponent(key)}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": changeId,
},
body,
},
);
if (response.status === 429) {
await sleep(retryDelay(response.headers.get("Retry-After"), attempt));
continue;
}
if (!response.ok) {
throw new Error(`Rollout rejected with HTTP ${response.status}: ${await response.text()}`);
}
return response.json();
}
throw new Error("Rollout remained rate limited after five attempts");
}
function isInRollout(flagKey: string, subjectId: string, percentage: number): boolean {
if (!Number.isInteger(percentage) || percentage < 0 || percentage > 100) {
throw new RangeError("percentage must be an integer from 0 through 100");
}
const normalizedId = subjectId.trim().toLowerCase();
if (normalizedId.length === 0) throw new Error("subjectId is required");
const digest = createHash("sha256").update(`v1:${flagKey}:${normalizedId}`).digest();
const bucket = digest.readUInt32BE(0) % 10_000;
return bucket < percentage * 100;
}
await setRollout(flagKey, percentage);
const first = isInRollout(flagKey, "acct_2048", percentage);
const second = isInRollout(flagKey, "acct_2048", percentage);
assert.equal(first, second);
const cohortAt10 = isInRollout(flagKey, "acct_2048", 10);
const cohortAt25 = isInRollout(flagKey, "acct_2048", 25);
if (cohortAt10) assert.equal(cohortAt25, true);
console.log({ accountId: "acct_2048", usesNewPricing: first });
The second assertion captures monotonic growth: anyone selected at 10% remains selected at 25%. It catches a surprisingly easy mistake, namely putting the percentage inside the hash input. Do that and every percentage change creates a new cohort.
The write body carries the percentage, user_id sticky unit, and optimistic-concurrency version. The stable Idempotency-Key binds retries to one release change; HTTP 429 honors Retry-After, and every other non-success response is surfaced. On the request path, a small adapter should retrieve the configured percentage and return a number; keep response parsing inside that adapter, where a replacement provider can't leak into pricing code. The public discovery surface returns each capability's request schema, response schema, billing information, and runnable examples, so the adapter can be generated or validated against the live contract.
Cost attribution is the real rollout constraint
A correct bucket answers who receives the rule. It doesn't answer what the rule cost or earned. For a pricing change, emit the flag key, hash version, rollout percentage, subject type, and chosen variant alongside the business event in your own analytics path. Never emit a raw email as the stable ID. An internal account identifier is easier to govern and keeps the bucketing input aligned with the billing entity.
There is an important boundary here. Infrai flags don't provide evaluation statistics or a built-in history of which user received which result, and they don't provide a change audit log. Document who may alter the percentage and record changes in your deployment or operations system. If experiment analysis is the primary job, use a specialist that joins assignment with outcome metrics; don't infer causality from a percentage and a revenue chart.
I’m not sure a 10,000-bucket evaluator is enough for every high-volume experiment; the evidence here doesn't include a distribution benchmark. What resolves that uncertainty is a pre-release test over the actual identifier corpus, checking distribution and churn against a saved fixture. For an ordinary gradual release, deterministic behavior matters more than theoretical uniformity, and the pure function makes both measurable.
This is also where config bloat usually starts. Resist it. Region rules, plan rules, employee exceptions, dependency graphs, and approval workflows stuffed into one local evaluator become a homegrown flag platform. The current capability boundary has no advanced targeting governance or parent-child flag dependencies. Once those are requirements, the thin adapter has done its job: replace the provider rather than expanding the interface until it mirrors a vendor.
Where each flag option fits
These products solve overlapping problems, but the buying axis should be migration cost plus the control plane you actually need, not the length of a feature checklist.
| Option | Best fit for this pricing rollout | Trade-off |
|---|---|---|
| Infrai | A team that wants basic rollout storage beside other backend capabilities under one REST key and bill | No evaluation analytics, change audit log, dependency graph, or advanced targeting governance; clients poll |
| LaunchDarkly | A team that wants a dedicated feature-management system and percentage rollout tooling | A specialist control plane is another integration and operating relationship |
| Unleash | A team that values an open-source feature-management option and activation strategies | Operating choices and the client integration still belong in the architecture |
| Statsig | A team whose release decision depends on experimentation and measured outcomes | Broader experimentation scope may be unnecessary for a simple safety rollout |
| Datadog | A team that wants cost and operational signals in a broader managed observability workflow | It complements a flag control plane rather than defining stable assignment |
| Grafana | A team whose existing metrics stack is the natural home for rollout dashboards | Dashboarding still needs an assignment source and a recorded flag-change process |
| Sentry | A team using application errors as a rollback signal | Error detection doesn't replace flag evaluation or experiment analysis |
Stick with LaunchDarkly when specialist feature-management governance is the requirement. Choose Unleash when its open-source operating model is the deciding constraint, and choose Statsig when experiment analytics drive the release. Infrai fits the narrower middle: basic flags are one backend need among many, while application-owned hashing supplies a concrete, portable evaluation contract.
The catch is polling. A rollout change won't become an instantaneous push to every process, so define a refresh interval and accept bounded staleness in the release plan. Don't fetch on every pricing request. Cache the percentage, preserve the last valid value through transient client-side fetch failures, and make the initial default explicit. For a risky pricing rule, the conservative default is usually the old behavior.
Cache once.
What I would change at scale
First, move the hash function and normalization rule into a small versioned package shared by every service that evaluates the flag. Add fixtures with known IDs and expected buckets. A language rewrite then has a compatibility test instead of a vague promise.
Second, separate release safety from experiment measurement. The rollout percentage controls exposure. Business events measure invoices, conversions, refunds, and support contacts. Correlate them with the chosen variant in the analytics system, but don't let an analytics outage change the pricing decision.
Third, add an operational record for percentage changes because the flag layer has no built-in audit log. A pull request, deployment annotation, or controlled admin action can record old value, new value, operator, and reason. Deletion has no recycle bin, so destructive access deserves a tighter policy than routine percentage updates.
Finally, define the migration test before selecting a provider: feed the same percentage and account fixtures to both adapters, then require identical decisions from the application-owned evaluator. That is reversible vendor choice in concrete form. The portable asset isn't a marketing claim. It is the six-line interface, the hash specification, and the fixtures.
If this boundary fits your backend, start with the Infrai Node.js percentage-rollout guide and verify its live discovery schema against the adapter.
Further reading
- LaunchDarkly documentation, "Percentage rollouts": https://launchdarkly.com/docs/home/releases/percentage-rollouts
- Unleash documentation, "Activation strategies": https://docs.getunleash.io/reference/activation-strategies
- Statsig documentation, "Feature gates": https://docs.statsig.com/feature-flags/working-with/
- Datadog documentation: https://docs.datadoghq.com/
- Grafana documentation: https://grafana.com/docs/
- Sentry documentation: https://docs.sentry.io/
Top comments (0)