|
| 1 | +# Budget the browser bundle size in CI |
| 2 | + |
| 3 | +- Status: proposed |
| 4 | +- Date: 2026-07-27 |
| 5 | + |
| 6 | +## Context |
| 7 | + |
| 8 | +Every htmdx artifact is a plain HTML file whose only runtime is one CDN script, |
| 9 | +`dist/browser.js`. That bundle carries React, the built-in catalog, the shadcn/ui |
| 10 | +pack, and the theme, so it is the entire loading cost of an artifact and the one |
| 11 | +number that decides how a document feels when it opens. |
| 12 | + |
| 13 | +Nothing measures it. The build has an assertion plugin |
| 14 | +(`build/production-bundle-validation.js`) that rejects development artifacts in the |
| 15 | +bundle, but nothing observes how large the bundle is. `bench/` measures token cost |
| 16 | +of htmdx source against other formats; CI does not run it, and it says nothing about |
| 17 | +bytes. |
| 18 | + |
| 19 | +The absence is already visible. `README.md` documents the CDN entry as `~145KB |
| 20 | +gzip` and `packages/htmdx/README.md` says `~147KB gzip`; the published 4.10.0 |
| 21 | +bundle is 519,890 bytes raw and 155,117 bytes gzipped. Two hand-maintained numbers |
| 22 | +disagree with each other and both understate reality, because nothing recomputes |
| 23 | +them and nothing fails when they drift. |
| 24 | + |
| 25 | +The growth vectors are ordinary work, not mistakes: a new built-in component, a |
| 26 | +Radix or `lucide-react` bump, a theme addition. Each is individually reasonable and |
| 27 | +none of them surface a size delta today. |
| 28 | + |
| 29 | +## Decision |
| 30 | + |
| 31 | +The build measures `dist/browser.js` and fails when it exceeds a committed budget. |
| 32 | + |
| 33 | +A pure helper, `build/bundle-budget.js`, takes measured byte counts and a budget and |
| 34 | +throws a message naming the file, the metric, the overage in bytes and percent, and |
| 35 | +the budget it exceeded. It follows `build/production-bundle-validation.js`: plain JS |
| 36 | +next to the build, no bundler coupling, unit-tested from `test/` over fixture inputs |
| 37 | +rather than over a real build. |
| 38 | + |
| 39 | +The vite browser config invokes it after the bundle is written, so the check sees the |
| 40 | +bytes that actually ship, sourcemap comment included. A breach fails |
| 41 | +`build:browser`, which fails `build:library`, which fails both CI jobs — the |
| 42 | +`test` job runs `yarn build` and the `e2e` job runs `yarn workspace @wix/htmdx |
| 43 | +build:library`. |
| 44 | + |
| 45 | +Two metrics are gated: raw bytes, which track parse and evaluation cost, and gzip at |
| 46 | +a pinned zlib level, which tracks transfer and is the unit the documentation and the |
| 47 | +ecosystem quote. Brotli is not measured at all. It is closer to what a CDN actually |
| 48 | +sends, but it moves with gzip, so gating it adds no signal, and compressing this |
| 49 | +bundle at brotli's default quality costs 772ms against gzip's 11ms — too much to |
| 50 | +spend on every build for a number nothing acts on. |
| 51 | + |
| 52 | +The budget lives in `build/bundle-budget.json` as a limit per metric plus a `note` |
| 53 | +recording what the limits were set from and why they last moved. Raising a limit is a |
| 54 | +normal, allowed act — the point is that it happens in the diff, under review, with a |
| 55 | +stated reason, instead of silently between releases. |
| 56 | + |
| 57 | +The gzip limit is 160 KiB exactly rather than a percentage above the measured size, |
| 58 | +because the README quotes it. A ceiling is a claim that stays true until the budget |
| 59 | +itself changes; a measured point value is a claim that goes stale on the next commit. |
| 60 | + |
| 61 | +Both README size figures are restated as that enforced ceiling and point at the |
| 62 | +budget file. |
| 63 | + |
| 64 | +## Alternatives |
| 65 | + |
| 66 | +- **`size-limit` with `@size-limit/file`** (v13.0.1, actively released). The |
| 67 | + ecosystem default, and it adds estimated download time on a slow connection for |
| 68 | + free. Rejected on fit: it brings two devDependencies and a separate CI step to |
| 69 | + produce a number that `node:zlib` produces in roughly forty lines, its presets |
| 70 | + earn their keep on multi-entry and code-split bundles rather than on a single |
| 71 | + IIFE, and it would sit beside the existing build-assertion seam instead of |
| 72 | + reusing it. This repo runs `knip` and `syncpack` over its dependency surface; |
| 73 | + paying two dependencies to avoid one small file is the wrong trade here. |
| 74 | +- **Report the size without failing.** Zero friction, and precisely the regime that |
| 75 | + produced two wrong numbers in the README. A number nobody is required to look at |
| 76 | + does not prevent drift. |
| 77 | +- **Comment the size delta against the base branch on each PR.** Better ergonomics |
| 78 | + than a hard failure, but it needs write-token plumbing, and fork PRs get nothing |
| 79 | + — the same constraint that already excludes forks from the preview workflow. It |
| 80 | + is an addition to a working gate, not a replacement for one. |
| 81 | +- **Measure in-browser time to `htmdx:ready` in the existing Playwright suite.** |
| 82 | + Closer to what a reader experiences, and deferred rather than rejected. Wall-clock |
| 83 | + timing on shared GitHub runners is noisy enough that the threshold has to be loose |
| 84 | + enough to miss real regressions, and for a single blocking CDN script bytes are |
| 85 | + the dominant term. Bytes first; timing when there is a question bytes cannot |
| 86 | + answer. |
| 87 | +- **Budget every `dist/*.js` entry.** The other entries are consumed through a |
| 88 | + bundler that tree-shakes them, so their raw size is a weak proxy for what a |
| 89 | + consumer pays. Scoped out deliberately. |
| 90 | + |
| 91 | +## Consequences |
| 92 | + |
| 93 | +Growing the artifact runtime becomes a visible act. Dependency bumps and new |
| 94 | +built-ins that push past the ceiling fail CI and require the author to either reduce |
| 95 | +the growth or raise the budget with a reason in the same PR. The repo has no |
| 96 | +Renovate or Dependabot configuration, so bumps are deliberate and infrequent; the |
| 97 | +expected rate of budget conversations is low. |
| 98 | + |
| 99 | +The gated numbers are computed with `node:zlib` at a pinned level. They will not |
| 100 | +match byte-for-byte what unpkg or jsdelivr serve, which use their own compression |
| 101 | +settings. This is acceptable because the budget is a regression guardrail: it needs |
| 102 | +to be deterministic and to move with real size, not to equal a CDN's output. The |
| 103 | +budget file records the compression settings so the figure is never mistaken for a |
| 104 | +transfer guarantee. |
| 105 | + |
| 106 | +The budget can be rubber-stamped. Nothing prevents raising it reflexively, and the |
| 107 | +mitigation is social — the delta and the reason are in the diff. This is a speed |
| 108 | +bump on unnoticed growth, not a lock on the bundle size. |
| 109 | + |
| 110 | +Turbo caches the `build` task with `dist/**` as its output. A cache hit skips the |
| 111 | +check, but a hit means the inputs were identical, so the size is identical. No |
| 112 | +enforcement is lost. |
0 commit comments