A percentage feature flag should map the same user to the same cohort on every request. Hash a durable user ID together with a flag key, convert that hash to one of 10,000 buckets, and enable the release when the bucket is below the requested percentage. Keep the algorithm and inputs fixed. Record the decision beside the media operation so a customer incident can be reconstructed later.
TL;DR: stability matters more than clever flag machinery. For a one-person SaaS shipping weekly, I want one small, testable function and enough evidence to answer four questions: which rule ran, which cohort the customer entered, which code path executed, and whether a rollback changed new requests. Random sampling cannot answer those questions reliably.
How should Node.js keep percentage feature flag rollouts stable?
Math.random() < 0.1 does put roughly 10% of evaluations on a new path over many trials. It does not create a stable 10% of users. One reader can upload an article image through the old processor, retry through the new processor, and then view a result produced by either path. The rollout has become request sampling.
That is especially awkward in media software. An incident report may need to connect a customer, an asset, a publish attempt, and the exact flag decision that selected a transformation path. If the choice changes during retries, the evidence describes several experiments at once. Rolling the percentage back to zero prevents later exposure, but it cannot explain the already completed work.
Retries expose it.
The durable unit must match the thing being released. Use a user ID for a user-level experience. Use an account ID when everyone in a newsroom must see the same workflow. For asset processing, an asset ID may be better if consistency across retries matters more than consistency across a customer session. Choosing that unit is a product decision, not a hashing detail.
I use the revenue-per-hour lens here. A sophisticated control plane is undifferentiated work if a pure function plus a stored configuration covers the release. The boring function is easy to review, easy to test, and small enough that it does not steal a weekly shipping cycle.
Ship the contract first.
The constraint that changes the design
Rollback safety is often described as an off switch. That is incomplete. A useful rollback also has a boundary: requests evaluated after the configuration change take the old path, while completed side effects remain attributable to the rule that created them. So I separate three records. The flag configuration says the intended percentage and a revision. The evaluation produces a decision. The media job records that decision with its own identifiers. The application can then turn a flag off without rewriting history. Do not use email addresses as cohort keys. They change, they carry personal data into places that do not need it, and differences in casing or normalization can move a person. An opaque, durable internal ID is cleaner. Also decide what happens before authentication. Silently swapping an anonymous browser ID for a user ID after login creates a cohort jump; either accept that transition explicitly or delay the flag until a stable identity exists.
History stays put.
There is another sharp edge: changing the flag key reshuffles everybody because the key is part of the hash input. That can be useful for a genuinely new experiment, but it is dangerous during a rollback-and-retry sequence. Treat the key and algorithm version as stored release data.
The smallest working backend implementation
This version uses Node's built-in cryptographic hash, takes the first 32 bits of the digest, and maps them to 10,000 integer buckets. Ten thousand buckets let a configuration represent hundredths of a percentage point without floating-point comparisons in the decision itself. The separator and version prefix make the input format explicit.
import { createHash } from "node:crypto";
type FlagDecision = {
enabled: boolean;
bucket: number;
flagKey: string;
flagRevision: number;
algorithm: "sha256-v1";
};
export function evaluatePercentageFlag(input: {
flagKey: string;
flagRevision: number;
subjectId: string;
percentage: number;
}): FlagDecision {
if (!Number.isFinite(input.percentage)) {
throw new TypeError("percentage must be finite");
}
const percentage = Math.min(100, Math.max(0, input.percentage));
const digest = createHash("sha256")
.update(`sha256-v1\u0000${input.flagKey}\u0000${input.subjectId}`, "utf8")
.digest();
const bucket = digest.readUInt32BE(0) % 10_000;
return {
enabled: bucket < Math.round(percentage * 100),
bucket,
flagKey: input.flagKey,
flagRevision: input.flagRevision,
algorithm: "sha256-v1",
};
}
Modulo reduction from a 32-bit number into 10,000 buckets is not perfectly uniform because 10,000 does not divide the 32-bit range evenly. For rollout assignment, the resulting bias is tiny, but what matters is documenting the method and keeping it unchanged. If a regulated or statistical use case requires exact rejection sampling, this compact rollout helper is the wrong primitive.
The percentage is clamped because configuration can be wrong. Whether invalid values should clamp or fail closed is a policy choice; pick one deliberately. I prefer rejecting non-numbers and clamping numeric bounds in this small example because it keeps the runtime path defined. A configuration validator should still reject values outside zero through 100 before deployment.
Pin known inputs.
A distribution test can pass while an implementation change moves every customer.
import assert from "node:assert/strict";
import test from "node:test";
import { evaluatePercentageFlag } from "./percentage-flag.js";
test("a subject keeps its bucket across evaluations", () => {
const input = {
flagKey: "media-renderer",
flagRevision: 7,
subjectId: "account_01J9R8K2",
percentage: 10,
};
assert.deepEqual(
evaluatePercentageFlag(input),
evaluatePercentageFlag(input),
);
});
test("zero and one hundred percent are hard boundaries", () => {
const base = {
flagKey: "media-renderer",
flagRevision: 7,
subjectId: "account_01J9R8K2",
};
assert.equal(evaluatePercentageFlag({ ...base, percentage: 0 }).enabled, false);
assert.equal(evaluatePercentageFlag({ ...base, percentage: 100 }).enabled, true);
});
Add golden tests for several fixed IDs before releasing this function. Those expected bucket numbers become a compatibility contract across refactors and languages. A rollout evaluator rewritten in another runtime must reproduce them exactly before it handles production traffic.
Retain the evidence, not every detail
For each media job, retain the flag key, configuration revision, algorithm version, bucket, decision, subject type, and a correlation identifier. The raw subject ID does not have to appear in a general-purpose log. A protected job record can hold the association while operational events carry a pseudonymous correlation value.
Log severity also needs discipline. RFC 5424 defines severity levels from Emergency through Debug and states that each level includes the numerically higher-severity conditions. A normal flag evaluation is not an error. Recording every decision at an error severity trains operators to ignore the channel they need during an incident. Emit a structured informational event when the audit requirement calls for it, and reserve error events for failed evaluation or failed work.
type MediaJobEvidence = {
jobId: string;
assetId: string;
flag: FlagDecision;
evaluatedAt: string;
};
export function evidenceForMediaJob(
jobId: string,
assetId: string,
flag: FlagDecision,
now: Date,
): MediaJobEvidence {
return {
jobId,
assetId,
flag,
evaluatedAt: now.toISOString(),
};
}
Store the decision at the point where the job is accepted, then pass it through retries. Re-evaluating a mutable percentage during each retry can change the path after an operator rolls forward or back. The stored decision answers what happened; the current configuration answers what should happen next. They are different questions.
Retention should follow the incident window and privacy policy of the service. There is no universal number in this design. Keep the smallest record that can join the customer report to the job and release revision, protect it like other operational data, and expire it on a declared schedule.
What I would change at scale
The local function is enough while one backend owns evaluation and configuration changes are controlled. At scale, I would outsource distribution of configuration and audit storage before outsourcing the decision semantics. Multiple services need one written contract for byte encoding, separators, hash algorithm, bucket count, and missing identities. Otherwise a user can land in different cohorts as work crosses service boundaries.
I would also add staged rollout policy around the function: internal accounts, a small external cohort, a larger cohort, then full release. Each promotion would require a fixed observation window and service-specific health checks. The exact percentages and window lengths depend on traffic and failure cost; invented universal thresholds would offer false confidence.
Native desktop crashes form a separate evidence stream. Electron's crashReporter documentation describes collection of native crash reports and minidumps. Those reports can help connect a desktop failure to an application version, but they do not replace the backend flag decision or media-job record. Correlation has to be designed across those boundaries without treating a crash dump as a complete incident history.
At high event volume, sample routine telemetry only after preserving the authoritative decision with the job. Sampling the sole audit record defeats reconstruction. Measure evaluator failures, missing subject IDs, configuration age, and outcome errors by flag revision. Avoid using conversion metrics as the rollback trigger when the immediate risk is corrupt or unavailable media; operational correctness comes first.
Trade-offs I would accept
This design intentionally gives up dynamic targeting rules, a visual editor, and automatic statistical analysis. In return, it has deterministic behavior, a reviewable compatibility contract, and a short rollback path. That is a reasonable exchange for a solo operator whose next hour could go to customer support or the feature customers actually pay for.
The hash does not make assignment secret. Anyone who knows the inputs and algorithm can calculate a bucket, so authorization must never depend on a feature flag. The function also does not make a database mutation reversible. Schema expansion, dual reads, backfills, and destructive cleanup need their own deployment plan. Turn the flag off first; repair side effects separately.
Flags aren't permissions.
Most importantly, rollback safety comes from preserved decisions and reversible system changes, not from the percentage slider. Stable cohorts remove one source of ambiguity. Evidence retention makes the remaining behavior explainable. Both fit in a small backend, and both are worth implementing before the first gradual media release.
Further reading
References used for the operational boundaries in this article:
- RFC 5424, The Syslog Protocol: https://datatracker.ietf.org/doc/html/rfc5424
- Electron
crashReporterdocumentation: https://www.electronjs.org/docs/latest/api/crash-reporter
Top comments (0)