WhiskerJS is about 25 kB over the network with gzip, and a check on every change stops it from quietly growing. While it collects, it pauses between its small measurements so that the page can keep reacting to clicks and typing.
Roadmap item N9. Two promises: the bundle stays small, and a collection does not freeze the page.
Bundle size
WhiskerJS publishes one entry, whiskerjs (dist/index.js, ES modules,
no runtime dependencies). pnpm run size bundles it the way an
integrator’s bundler would ship it (esbuild, ESM, minified, browser
platform, target ES2020) and compresses that bundle with gzip (level 9) and
brotli (quality 11) from Node’s zlib.
Measured on main at 3f63381 (after PR #36; version 0.1.0, signalsVersion
14, idVersion 2, 62 components, 26 integrity flags; kB here is 1024 bytes,
as pnpm run size prints it):
| Entry | Bundled, not minified | Minified | Minified + gzip | Minified + brotli |
|---|---|---|---|---|
whiskerjs |
128,227 B (125.2 kB) | 72,678 B (71.0 kB) | 25,859 B (25.3 kB) | 22,618 B (22.1 kB) |
Earlier measurements, same method:
| Commit | Components | Bundled, not minified | Minified | Minified + gzip | Minified + brotli |
|---|---|---|---|---|---|
| PR #35 (canvas against OffscreenCanvas) | 60 | not recorded | 72,060 B | 25,582 B | 22,443 B |
| PR #34 (five consistency flags) | 59 | 125,285 B | 71,269 B | 25,356 B | 22,272 B |
| PR #33 (engine version vector) | 59 | 118,272 B | 67,319 B | 23,955 B | 21,035 B |
| a5bde97 (after PR #30) | 57 | 106,745 B | 60,297 B | 21,189 B | 18,785 B |
| PR #29 (ten components outside the ids) | 57 | not recorded | 60,157 B | 21,149 B | 18,698 B |
| PR #28 (size and speed budgets) | 47 | 97,706 B | 54,646 B | 19,453 B | 17,363 B |
| 4379fe7 (before PR #28) | 47 | not recorded | 53,091 B | 18,968 B | 16,940 B |
PR #36 (two media queries and the fonts probes in steps) added 618 B
minified (277 B gzip, 175 B brotli) and PR #35 (canvas_offscreen and
its flag) 791 B minified (226 B gzip, 171 B brotli), both inside the
budget. PR #34 (five consistency flags) added 3,950 B minified (1,401 B gzip)
and raised the budget to match. PR #33 (the engine version vector: a feature table, 16 tiny WebAssembly
modules, two components and one integrity check) added 7,022 B minified
(2,766 B gzip) and raised the budget to match. PR #29 raised the budget
to match (below); PR #30 added 140 B minified (40 B gzip, 87 B brotli). Per-component timing (PR #27) and the yielding
below added about 0.5 kB gzip.
pnpm run size also lists the largest modules in the minified bundle; the
integrity checks (integrity.js, 11,540 B minified at 3f63381, with
consistency.js, 3,214 B) are the largest, then the engine feature table
(engine.js, 5,956 B).
The budget
size-budget.json holds the budget per entry, set about 3% above the size
measured when it was last changed:
| Entry | Minified | Gzip | Brotli |
|---|---|---|---|
whiskerjs |
73,500 B | 26,200 B | 23,000 B |
(Set in PR #34; it was 69,400 / 24,700 / 21,800 B in PR #33, 62,000 / 21,800 / 19,300 B in PR #29 and 56,300 / 20,000 / 17,850 B in PR #28.)
CI runs pnpm run size:check after the build in the unit-test job; it
fails when any of the three sizes is over its budget.
Raising the budget on purpose
Growth is allowed when it is deliberate and visible:
pnpm build && pnpm run sizeand note the new sizes.- In the same pull request that adds the bytes, set each number in
size-budget.jsonabout 3% above the new size and updatemeasuredAt. - Say in the pull request and in
CHANGELOG.mdwhat the bytes buy, and update the tables above.
Never raise the budget in a separate pull request, and never to make room for later changes.
Main-thread budget
A browser counts any task of 50 ms or more as a long task; long tasks delay input and hurt Interaction to Next Paint. Until this change, the known-answer probes and the synchronous part of all the collectors (47 then, 62 now) ran in one task.
Now collect() gives the main thread back between steps: between the
canvas and the WebGL known-answer probes, before each collector that runs
(excluded collectors do not run, so no break is needed for them), and
before the integrity checks. Collectors still run side by side: the next
one starts after the break, without waiting for the previous one to
settle. The break is scheduler.yield() where the browser has it
(Chromium), else a MessageChannel message, else setTimeout(0); each is
raced with a setTimeout(0), so a missing or stalled yield cannot hold up
the collection. load({ env: { yieldToMain } }) replaces it; a function
that returns no promise turns yielding off.
What does not change:
- No collected value, id or flag: a unit test collects with yielding off,
through
MessageChannel, throughsetTimeout, throughscheduler.yield()and with slow breaks, and compares every component, both ids, the previous ids and the flags. - The overall budget: each collector is still capped by the overall
deadline from its own start; a collector that starts after the deadline
still runs its synchronous part, as before. The inner timeout (1000 ms)
starts once every collector has been started (since PR #30), so the
breaks and the synchronous work of the collectors that start later are
not charged to it. An abort
resolves every collector that has not settled, including those not yet
started, as
timed_out/aborted. - A single collector’s own synchronous work is still one task. Where a collector alone takes 50 ms or more (a first WebGL context or canvas render on a machine without a GPU, for example), that step is still a long task.
The cost: a collection takes slightly longer in total, because the page’s own tasks run in the breaks.
Measured long tasks
e2e/longtask.spec.mjs loads the test page five times with yielding off
(?yield=off, the old behaviour) and five times with it on, interleaved,
and records the long tasks inside collect() with a PerformanceObserver
for longtask entries. Only Chromium-based browsers report long tasks;
the spec skips Firefox and WebKit. It also checks that every run gives the
same ids.
Hosted CI, GitHub’s ubuntu-latest runner without a GPU (CI run
40464666679, commit c39ffe1; two jobs, each five runs per mode and
browser; longest long task per collection, in ms):
| Browser | Yielding off: longest task | Yielding on: longest task | Long tasks per collection, off / on | collect() median, off / on |
|---|---|---|---|---|
| Chromium 153 (Playwright headless shell) | 84 to 102 (medians 100 and 84) | none in 10 of 10 runs | 1 / 0 | 123 / 138 ms and 105 / 115 ms |
| Google Chrome 155 (branded, headless) | 187 to 272 (medians 240 and 209) | 51 to 77 (medians 69 and 58); none in 1 of 10 runs | 1 / 1 | 276 / 264 ms and 234 / 254 ms |
With yielding on, the one long task left in branded Chrome was a single
step: the fonts collector, whose span probe laid out all 165 spans (3
fallback spans and 3 for each of 54 families) in one pass (its own
durationMs matched the task, 58 to 77 ms). PR #36 splits it (below). The
total time of a collection grew by about 10 ms in Chromium and stayed
within the run-to-run spread in Chrome. One earlier run (before the two
known-answer probes were split) showed a single 593 ms outlier in
Chromium with yielding on; it did not recur in the ten runs above.
The fonts probes in steps (PR #36)
The fonts probe now lays out the 3 fallback spans first and then the
54 families in six steps of 9 (27 spans each), each step appended,
measured in one layout pass and removed, with a break for the page
between steps. fonts_canvas, which measures the same families with
canvas measureText, takes the same steps: once fonts gave the page
breaks, fonts_canvas started before the browser had looked the fonts
up and took over the long task (106 ms in one run). Every width is
measured as before, so neither value nor any id changes (unit tests
replay the values the one-pass probes gave).
Since PR #32 the hosted real-browser jobs run in Playwright’s container
image, where branded Chrome showed almost no long task even before the
split. A CI job (Long tasks (runner's Chrome, no container)) measures
on the plain runner, as in the table above. Branded Chrome 155, yielding
on, five collections per run and two runs per side:
| Code | Runs | Collections with a long task | Long tasks (ms) | collect() median |
|---|---|---|---|---|
| Before (main at 97f325c, one pass) | 40481669895, 40481982559 | 7 of 10 | 64, 88, 85, 93, 71 (one run also 52); 60, 52: the fonts step each time |
355 and 240 ms |
| After (PR #36) | 40481979304, 40481985432 | 1 of 10 | 56, at 216 ms into the collection, not a fonts step | 362 and 347 ms |
With yielding off (the old single task) the same runs measured 276 to 316 ms before and 279 to 329 ms after. The total time of a collection is unchanged within the run-to-run spread.