A staged rollout sounds like a sample that rotates. In electron-updater it does not. The install draws one id, once, and keeps it forever. The version string never enters the bucketing formula, so every release at 10 percent ships to the same 10 percent. This page runs that formula against generated ids and measures what the omission costs you, then runs the salted variant that mature rollout systems use, so the difference is readable off one screen.
The shape of the staged rollout check as shipped in electron-updater's AppUpdater.ts:
id = UUID.v5(randomBytes(4096), UUID.OID) // persisted once to .updaterId
val = UUID.parse(id).readUInt32BE(12)
pct = val / 0xffffffff
return pct < stagingPercentage
Four things are absent from that expression: the version, the channel, the release date, and any per-release salt. The bucket is a pure function of a persisted id, so it is fixed for the life of the install. Everything below follows from that one omission.
Every install id is drawn fresh from
crypto.getRandomValues on each run. Nothing here is precomputed, so the cohort sizes move
between runs while the structural results do not. That contrast is the point: run it twice.
No run yet. Press Run simulation above.
Raising the gate can only add installs. Anything inside the 10 percent gate is
inside every larger gate, because the test is bucket < gate against a bucket that never
changes. Counted per install below, not assumed:
| Gate | In cohort | Share | Added | Dropped | Subset holds |
|---|
| Release | Cohort | Overlap with previous |
|---|
| Release | Cohort | Overlap with previous |
|---|
Overlap is
|cohort(r) intersect cohort(r-1)| / |cohort(r-1)|: the share of last release's canaries
that are canaries again. At 1.0000 the same machines absorb every bad build you ever ship, and the rest
of the base is a permanent control group that tests nothing.
Ever canaried is the union across all simulated releases: the share of the base that has taken at least one staged build. Under the shipped formula it equals the cohort size no matter how many releases you run. That is the number to take to a release meeting.
Both columns above ran over the same install ids, in the same tab, in the same run. This is the only difference between them:
- val = bucket(installId) // shipped: releaseId is not an input + val = bucket(hash(installId, releaseId)) // salted: one extra input return val / 0xffffffff < stagingPercentage / 100
Hashing a per-rollout identifier together with the unit id is the convention percentage-rollout systems such as LaunchDarkly, Unleash and Statsig follow: the bucket stays stable within a rollout and is independent across rollouts. This simulator uses murmur3 32-bit over the two ids. The choice of hash is not the finding. The missing input is the finding.
Paste any UUID. The page computes the bucket both ways, Node's
readUInt32BE(12) by explicit byte composition and the browser's
parseInt(hex.slice(24, 32), 16), and reports whether they agree. No
crypto.subtle is needed for the bucket math and none is used.
| Staging percentage | Comparison | Receives the build |
|---|
The divisor is 0xffffffff, not 0x100000000. An install
whose 32 bits are all ones therefore yields a percentage of exactly 1.0, and
1.0 < 1.0 is false, so that install is excluded even from a 100 percent staged release.
It is a genuine off-by-one in the shipped comparison.
It is also unreachable. Both halves, computed on load:
Report it, do not lead with it. A reader who does the arithmetic and catches you overselling a one-in-four-billion event will stop trusting the rest of the page. The nesting result above is the one that costs real money.
The channel setter sets allowDowngrade to true
unconditionally. Switching a user to a channel for any reason, including moving them off a broken
beta, silently enables downgrades as a side effect. The two decisions are not separable at the call
site: you cannot set a channel and keep downgrades off.
There is no rollback lever. Because the update decision is driven by version comparison, withdrawing a bad staged build means publishing a version higher than the broken one. Re-publishing the same version number leaves everyone who already took the bad build stuck on it. Combine that with the nesting result and the shape is clear: one fixed cohort eats every bad build, and then waits on your ability to ship a version bump.
The id draw. The shipped code generates 4096 random bytes, takes a UUID v5
of them, persists it, and then reads exactly four bytes back out: bytes 12 to 15. UUID v5 puts the
version bits in byte 6 and the variant bits in byte 8, so bytes 12 to 15 carry no forced bits and the
bucket is a uniform 32-bit draw. This page draws those 32 bits directly with
crypto.getRandomValues: same distribution, four bytes per install instead of 4096, which is
what lets 200,000 installs times a dozen cohorts finish without pinning the tab. The inspector above runs
the full UUID path, so the byte math is checkable on real ids rather than taken on trust.
Where the work happens. The simulation runs in a worker built from a blob
when the browser allows it, and falls back to time-sliced chunks on the main thread when it does not,
for instance under a strict file:// origin. Each run reports which path it took. Neither
path is a blocking loop.
What is invariant and what is not. Cohort sizes are random and move on every run. The structural results do not move: the smaller cohort is a strict subset of the larger, raising the gate drops zero installs, and consecutive releases under the shipped formula select the identical set at overlap exactly 1.0000. If a run ever shows otherwise, the simulator is wrong, and you should not believe the rest of it either.