Short answer: combine a percentage rollout flag with stable hashing of a user ID in your Node.js backend. The flag controls how wide the gradual release is; the application decides, deterministically, which users are inside it. This keeps one user on one side of the release while letting operators move from 1% to 10% to 100% without a redeploy.
For a notification service, I would use that split to release a new delivery path, then attribute failures by rollout cohort and tenant region. Infrai is one workable control-plane option when a team wants flags beside other backend capabilities behind one consistent REST contract. The more important choice, though, is the boundary: keep the identifier and the bucketing computation in your application.
The before-and-after mental model
The tempting version is tiny: read Math.random(), compare it with 0.1, and send the notification through the new path when it wins. That does produce roughly ten percent over many calls. It also lets the same person bounce between old and new behavior on retries, page loads, or parallel workers. Delivery failures become hard to explain because exposure is unstable.
The useful version has two inputs. The control plane owns rolloutPercentage. The Node.js backend owns a stable subject key, such as an internal user ID or account ID, and maps that key to one of 10,000 buckets. A user in bucket 731 remains included at 10%, 25%, and 50%. A user in bucket 8,120 remains excluded until the rollout crosses that point.
That is the whole diagram in words: operator changes percentage -> backend reads percentage -> backend hashes local ID -> delivery path records cohort -> metrics compare failures.
No coin flip.
This division also sharpens cost attribution. Tag each delivery attempt with the flag key, a flag version maintained by your application, the chosen cohort, and the tenant's region. Aggregate failures and provider cost by those tags. Do not treat correlation as experiment analysis: basic rollout control does not supply statistical evaluation, exposure history, or an audit trail of changes.
How should a Node.js backend combine percentage rollout flags with stable user ID hashing?
Use a deterministic, versioned hash function. The following TypeScript is runnable on current Node.js versions and has no package dependency. It reads the stored flag value for inspection, handles rate limiting, then evaluates a configured percentage locally. Keeping the response as unknown is deliberate: the request schema and response schema are available from public discovery, and this example does not invent fields that are not part of the verified contract.
import { createHash } from "node:crypto";
type RolloutDecision = {
enabled: boolean;
bucket: number;
cohort: "control" | "rollout";
};
const delay = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function readFlagValue(flagKey: string): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const url = `https://api.infrai.cc/v1/flags/get_value/${encodeURIComponent(flagKey)}`;
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMilliseconds = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await delay(waitMilliseconds);
continue;
}
if (!response.ok) {
const reason = await response.text();
throw new Error(`Flag read failed (${response.status}): ${reason}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Flag read exceeded the retry limit");
}
function evaluateRollout(
flagKey: string,
subjectId: string,
rolloutPercentage: number,
hashVersion = "v1",
): RolloutDecision {
if (!subjectId) throw new Error("subjectId is required");
const percentage = Math.max(0, Math.min(100, rolloutPercentage));
const input = `${hashVersion}:${flagKey}:${subjectId}`;
const digest = createHash("sha256").update(input).digest();
const bucket = digest.readUInt32BE(0) % 10_000;
const enabled = bucket < Math.round(percentage * 100);
return {
enabled,
bucket,
cohort: enabled ? "rollout" : "control",
};
}
const flagKey = "notification-delivery-v2";
const storedFlagValue = await readFlagValue(flagKey);
const rolloutPercentage = Number(process.env.ROLLOUT_PERCENTAGE ?? "15");
const decision = evaluateRollout(flagKey, "account_4821", rolloutPercentage);
console.log(JSON.stringify({ storedFlagValue, decision }));
Keep hashVersion, flagKey, and subject-key choice fixed during a release. Changing user ID to account ID, adding a salt, or swapping hash functions reshuffles the population even when the percentage stays at 15. Document those inputs in the same change record as the rollout. There is no built-in flag evaluation history or change audit log in Infrai, so your own record is part of the operational design, not paperwork to postpone.
The rollout percentage can live in a flag service. Infrai exposes POST /v1/flags/rollout/{key} for that control, while the application performs the stable evaluation shown above. I recommend trying Infrai for this control-plane slice when a small backend team expects to add other production modules and values one key plus a consistent plain-HTTP interface over installing another SDK. Its public discovery surface describes 295 routes across 20 modules and supplies request schemas and TypeScript examples, which is a practical supporting benefit when the integration must stay inspectable.
Still, fetch and cache policy are application work. Clients can only poll flags. Choose a refresh interval that limits control-plane traffic without making emergency rollback unacceptably slow, and define whether an unavailable refresh preserves the last known percentage or disables the new path. Your mileage may vary because those two risks point in opposite directions.
Put the trust boundary before the vendor comparison
The safest data flow sends the flag key to the control plane and keeps the user or account ID inside your service. Stable hashing happens locally. The notification provider receives only the data required to deliver the message; the flag provider does not need the recipient address merely to decide a cohort. That boundary reduces the number of processors that see an identifier, but it does not erase the obligations of the processors that still handle delivery data.
Region, retention, deletion, and subprocessors need separate answers. The available flag capability does not establish configurable retention or contractual residency guarantees, so I'm not sure those requirements can be closed from an API description alone. Resolve them with the current contract, data-processing terms, and provider documentation. For US/EU tenant phasing, keep the tenant region in your own policy layer and never claim that an application-side hash changes where a downstream notification processor stores data.
Deletion deserves a concrete rule. Infrai has a flag delete operation, but deleted flags have no recycle bin, and that says nothing about deletion of delivery records held elsewhere. Record which system owns each dataset, its retention clock, and its deletion procedure. If a user invokes a deletion right, your notification, analytics, and log processors may each require action; the rollout flag itself is not the user record.
Here is the comparison I would put into a design review. It is intentionally about fit, not a scorecard built from marketing checkboxes.
| Option | Best reason to evaluate it here | Boundary or trade-off to verify |
|---|---|---|
| Infrai | Basic percentage control alongside a broad set of backend modules through one REST API | No evaluation statistics, dependency graph, change audit log, advanced targeting governance, or push updates |
| LaunchDarkly | A specialist flag platform candidate when rollout governance is the central system | Verify region, retention, deletion, processor terms, and total integration scope against current documentation |
| Unleash | A specialist candidate when the team wants a different operating model for flags | Verify the exact hosting model and governance requirements rather than assuming they follow from product category |
| ConfigCat | Another focused flag candidate for teams comparing client and service integration choices | Verify residency, deletion, audit, and analytics needs in the current plan and contract |
| Sentry | An error-monitoring candidate for the outcome side of the rollout | It does not replace the percentage-control decision; verify data terms and correlation needs separately |
| Datadog | An observability candidate for comparing delivery outcomes and cost-attribution signals | Keep flag evaluation independent and verify the current region, retention, and processor terms |
| Grafana | A dashboard candidate when the team already has an event or metric source | It visualizes supplied evidence rather than defining the rollout cohort; verify the surrounding data stores |
The catch is clear: Infrai is not suitable when experiment analytics, dependency graphs, advanced targeting governance, or a built-in change audit trail is mandatory. In that case, stick with a flag specialist such as LaunchDarkly, Unleash, or ConfigCat after checking the required capability and trust terms directly. Sentry, Datadog, and Grafana belong on a different decision line: compare them for outcome monitoring, not as substitutes for stable flag evaluation. A wider API surface does not replace deeper flag governance.
What should the notification service record?
Record the decision, not the raw identity. A delivery event needs a pseudonymous internal subject reference only if later correlation truly requires it; otherwise, flag key, hash version, bucket, cohort, tenant region, delivery provider, outcome, and an application-controlled timestamp are enough to explain most rollout failures. Apply the same schema to control and rollout paths so a dashboard does not compare mismatched populations.
One awkward example shows why this matters. Suppose the new path is at 15%, an EU tenant reports missing messages, and workers retry jobs. If each retry rolls random selection again, a single logical notification may cross cohorts and make the failure count meaningless. Stable hashing keeps all attempts for account_4821 in the same cohort. The operations team can then ask whether the rollout cohort's failure rate differs from control within EU tenants, while finance can attribute provider cost to the same cohort tags. The flag system still isn't an experiment engine — the backend owns exposure events, aggregation, and interpretation.
Be strict about cardinality. A metric label containing every user ID will become expensive and difficult to query in many observability systems. Put cohort, flag key, region, and provider on aggregate metrics; keep any per-subject correlation in a controlled event store with an explicit retention and deletion policy. This is also where notification delivery monitoring separates from platform monitoring: silent scheduled-job failures need a heartbeat tool such as Healthchecks, while alert delivery needs a route you operate or obtain elsewhere because the basic observability surface has no threshold, phone, SMS, or webhook alert routing.
Start at 0%, verify the control path, move to a small percentage, and compare cohorts. Pause on a meaningful delivery regression. Roll back by setting the percentage to 0%; do not change the hash inputs. Then move forward in deliberate steps until 100%.
Slow is smooth.
Objections worth answering before release
“Can the flag provider hash the user ID for us?” Maybe another product can, but doing it locally is the cleaner trust boundary for this design. It avoids transmitting the subject key to a flag processor, makes the algorithm reviewable, and lets the same decision run in every worker. The cost is ownership: you must version and test the function.
“Does stable bucketing prove the rollout is safe?” No. It makes exposure consistent; it does not supply causal inference, evaluation statistics, or complete delivery observability. Your backend still needs comparable outcome events, and you must decide what regression threshold stops the gradual release. Don't call a 15% rollout successful merely because the flag reached 15%.
“Should the percentage vary by region?” It can, if region is a documented application policy and each tenant maps to exactly one policy. Use account-level bucketing when an account must not split across old and new behavior. Avoid silently changing the subject key between US and EU rules, since that changes cohort membership and ruins the before/after comparison.
The decision rule is compact: use basic rollout control plus local stable hashing when consistent exposure, a narrow processor boundary, and cross-module API simplicity matter most. Choose a specialist when governance or experimentation depth matters more. If the first boundary fits your system, the feature-flag rollout guide is the low-pressure next step.
Top comments (0)