a disproof, not a visualiser

Staged Rollout Cohort Simulator

Runs the shipped electron-updater bucketing formula against freshly generated install ids, live, in this tab.

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 formula under test

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.

Run the simulation

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.

Result

No run yet. Press Run simulation above.

4. Bucket inspector: check the byte math yourself

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.

5. The 0xffffffff off-by-one: real, and it will never bite anyone

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.

6. Two more findings from the same update path

Setting a channel unconditionally arms downgrades

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.

Pulling a bad staged release requires a version above the broken one

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.

7. Method, and why this is a fair test

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.