Skip to content

Commit 7f8c8d3

Browse files
authored
ci: budget the browser bundle size (#81)
Nothing measured dist/browser.js, the single CDN script every htmdx artifact loads before it renders. The two hand-maintained figures in the READMEs had already drifted apart and away from reality: ~145KB and ~147KB gzip documented against 156,247 measured. The build now measures the written bundle against a committed budget and fails when it grows past the ceiling. Raising a limit stays allowed, but it happens in the diff with a stated reason instead of silently between releases.
1 parent 35034b2 commit 7f8c8d3

8 files changed

Lines changed: 362 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ The authoring guidance ships with the runtime and is printed by `htmdx skill`, s
298298

299299
## Package
300300

301-
- npm: `@wix/htmdx` · CDN entry: `dist/browser.js` (~145KB gzip) · module entries: `.`, `./react`, `./testing`, `./components`, `./components/builtins`, `./components/shadcn`
301+
- npm: `@wix/htmdx` · CDN entry: `dist/browser.js` (under 160KB gzip, [budgeted in CI](./packages/htmdx/build/bundle-budget.json)) · module entries: `.`, `./react`, `./testing`, `./components`, `./components/builtins`, `./components/shadcn`
302302
- custom element: `<htmdx-code>` · browser API: `window.Htmdx`
303303
- linting: [`validate()`](#validation-and-linting) · CLI: [`lint`, `compile`, `components`](#command-line)`npx @wix/htmdx lint <files...>`
304304
- component contract: `dist/components.json`
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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.

packages/htmdx/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,8 +389,12 @@ The standard runtime script gives an artifact the full catalog:
389389

390390
`dist/browser.js` bundles React, the built-in catalog (ExecutiveSummary,
391391
MetricStrip, charts, ...), the shadcn/ui pack (Card, Badge, Button, Tabs,
392-
Accordion), and the shadcn theme (~147KB gzip, including the static-render
393-
path that powers `compile()`).
392+
Accordion), and the shadcn theme, including the static-render path that powers
393+
`compile()`. It stays under 160KB gzip: the build measures the written bundle
394+
against
395+
[`build/bundle-budget.json`](https://github.com/wix-incubator/htmdx/blob/master/packages/htmdx/build/bundle-budget.json)
396+
and fails when it grows past the ceiling, so the figure cannot drift without a
397+
reviewed change to the budget.
394398

395399
Authoring htmdx source instead of rendered markup is measurably cheaper for
396400
agents: the full single-file artifact is about 4.3x smaller in tokens than the
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export type BundleSizes = {
2+
raw: number;
3+
gzip: number;
4+
};
5+
6+
export declare function measureBundle(contents: Uint8Array): BundleSizes;
7+
export declare function checkBudgetCoverage(
8+
budgetedFileNames: readonly string[],
9+
emittedFileNames: readonly string[],
10+
): void;
11+
export declare function formatBundleSizes(
12+
fileName: string,
13+
sizes: BundleSizes,
14+
limits: BundleSizes,
15+
): string;
16+
export declare function checkBundleBudget(
17+
fileName: string,
18+
sizes: BundleSizes,
19+
limits: BundleSizes,
20+
): void;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { gzipSync } from 'node:zlib';
2+
3+
// Pinned so the number is reproducible across machines and Node releases. It is
4+
// not what a CDN serves - unpkg and jsdelivr compress with their own settings -
5+
// and it does not have to be. The budget guards against growth, so the metric
6+
// only has to be deterministic and to move with the real size.
7+
const GZIP_LEVEL = 9;
8+
9+
const METRIC_LABELS = {
10+
raw: 'raw',
11+
gzip: `gzip (zlib level ${GZIP_LEVEL})`,
12+
};
13+
14+
export function measureBundle(contents) {
15+
return {
16+
raw: contents.length,
17+
gzip: gzipSync(contents, { level: GZIP_LEVEL }).length,
18+
};
19+
}
20+
21+
export function formatBundleSizes(fileName, sizes, limits) {
22+
const parts = Object.keys(METRIC_LABELS).map((metric) => {
23+
const used = Math.round((sizes[metric] / limits[metric]) * 100);
24+
return `${metric} ${bytes(sizes[metric])} (${used}% of ${bytes(limits[metric])})`;
25+
});
26+
27+
return `${fileName}: ${parts.join(', ')}`;
28+
}
29+
30+
// A budget keyed on a file the build no longer emits enforces nothing, and it
31+
// does it silently: the build stays green while the gate is gone. Renaming an
32+
// output has to fail loudly rather than switch the check off.
33+
export function checkBudgetCoverage(budgetedFileNames, emittedFileNames) {
34+
const emitted = new Set(emittedFileNames);
35+
const missing = [...budgetedFileNames].filter((fileName) => !emitted.has(fileName));
36+
37+
if (!missing.length) {
38+
return;
39+
}
40+
41+
throw new Error(
42+
`build/bundle-budget.json budgets a file the build did not emit: ${missing.join(', ')}. ` +
43+
`The build emitted: ${emittedFileNames.join(', ') || '(nothing)'}. ` +
44+
'Point the budget at the current output name, or the size gate silently stops running.',
45+
);
46+
}
47+
48+
export function checkBundleBudget(fileName, sizes, limits) {
49+
const breaches = Object.entries(METRIC_LABELS)
50+
.filter(([metric]) => sizes[metric] > limits[metric])
51+
.map(([metric, label]) => {
52+
const over = sizes[metric] - limits[metric];
53+
const percent = ((over / limits[metric]) * 100).toFixed(1);
54+
return `${label}: ${bytes(sizes[metric])} bytes, ${bytes(over)} over the ${bytes(limits[metric])} budget (+${percent}%)`;
55+
});
56+
57+
if (!breaches.length) {
58+
return;
59+
}
60+
61+
throw new Error(
62+
`${fileName} exceeds its size budget:\n\n- ${breaches.join('\n\n- ')}\n\n` +
63+
'Every htmdx artifact loads this bundle from a CDN, so the growth is paid on ' +
64+
'every page open. Either bring it back under the budget, or raise the limit in ' +
65+
'build/bundle-budget.json and record why in its note - in this change, not later.',
66+
);
67+
}
68+
69+
function bytes(value) {
70+
return value.toLocaleString('en-US');
71+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"$comment": "Size ceilings for the bundle a browser downloads. Enforced by vite.browser.config.ts on every build:browser, so a breach fails CI. Raising a limit is allowed - record why in its note, in the same change. Bytes, measured on the written file; gzip is node:zlib at level 9, which is not what a CDN serves and does not need to be.",
3+
"files": {
4+
"browser.js": {
5+
"raw": 550000,
6+
"gzip": 163840,
7+
"note": "Set from 4.10.0, which measured 521,378 raw and 156,247 gzip. The gzip limit is 160 KiB exactly, because README.md quotes it as the enforced ceiling."
8+
}
9+
}
10+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, test } from 'vitest';
2+
import {
3+
type BundleSizes,
4+
checkBudgetCoverage,
5+
checkBundleBudget,
6+
formatBundleSizes,
7+
measureBundle,
8+
} from '../build/bundle-budget.js';
9+
10+
const LIMITS: BundleSizes = { raw: 500_000, gzip: 150_000 };
11+
12+
describe('bundle budget build validation', () => {
13+
test('accepts a bundle under both limits', () => {
14+
expect(() =>
15+
checkBundleBudget('browser.js', { raw: 499_999, gzip: 149_999 }, LIMITS),
16+
).not.toThrow();
17+
});
18+
19+
test('accepts a bundle exactly at its limits', () => {
20+
expect(() => checkBundleBudget('browser.js', { ...LIMITS }, LIMITS)).not.toThrow();
21+
});
22+
23+
test('rejects a bundle over the raw limit alone', () => {
24+
expect(() => checkBundleBudget('browser.js', { raw: 500_001, gzip: 149_999 }, LIMITS)).toThrow(
25+
'raw',
26+
);
27+
});
28+
29+
test('rejects a bundle over the gzip limit alone', () => {
30+
expect(() => checkBundleBudget('browser.js', { raw: 499_999, gzip: 150_001 }, LIMITS)).toThrow(
31+
'gzip',
32+
);
33+
});
34+
35+
test('names the offending file', () => {
36+
expect(() => checkBundleBudget('browser.js', { raw: 600_000, gzip: 150_000 }, LIMITS)).toThrow(
37+
'browser.js',
38+
);
39+
});
40+
41+
test('reports the overage in bytes and percent', () => {
42+
let message = '';
43+
try {
44+
checkBundleBudget('browser.js', { raw: 550_000, gzip: 150_000 }, LIMITS);
45+
} catch (error) {
46+
message = (error as Error).message;
47+
}
48+
expect(message).toContain('50,000 over the 500,000 budget');
49+
expect(message).toContain('+10.0%');
50+
});
51+
52+
test('reports every breach in one failure, and how to resolve it', () => {
53+
let message = '';
54+
try {
55+
checkBundleBudget('browser.js', { raw: 600_000, gzip: 160_000 }, LIMITS);
56+
} catch (error) {
57+
message = (error as Error).message;
58+
}
59+
expect(message).toContain('raw');
60+
expect(message).toContain('gzip');
61+
expect(message).toContain('build/bundle-budget.json');
62+
});
63+
64+
test('measures raw bytes and a smaller gzip figure', () => {
65+
const sizes = measureBundle(Buffer.from('htmdx'.repeat(1000)));
66+
67+
expect(sizes.raw).toBe(5000);
68+
expect(sizes.gzip).toBeGreaterThan(0);
69+
expect(sizes.gzip).toBeLessThan(sizes.raw);
70+
});
71+
72+
test('accepts a budget whose files the build emitted', () => {
73+
expect(() =>
74+
checkBudgetCoverage(['browser.js'], ['browser.js', 'browser.js.map']),
75+
).not.toThrow();
76+
});
77+
78+
test('rejects a budget keyed on a file the build no longer emits', () => {
79+
let message = '';
80+
try {
81+
checkBudgetCoverage(['browser.js'], ['runtime.js']);
82+
} catch (error) {
83+
message = (error as Error).message;
84+
}
85+
expect(message).toContain('browser.js');
86+
expect(message).toContain('runtime.js');
87+
expect(message).toContain('silently stops running');
88+
});
89+
90+
test('formats each metric against the share of budget it uses', () => {
91+
expect(formatBundleSizes('browser.js', { raw: 250_000, gzip: 75_000 }, LIMITS)).toBe(
92+
'browser.js: raw 250,000 (50% of 500,000), gzip 75,000 (50% of 150,000)',
93+
);
94+
});
95+
});

0 commit comments

Comments
 (0)