Measuring gzip and Brotli Bundle Size in a Vite Build
The raw file size Vite prints is not what users download — the CDN serves a compressed body, so the number that matters is the gzip or Brotli size. This guide measures both per chunk, explains which one a client actually receives, and scripts a report you can budget against. It sharpens the metric discussion in bundle analysis and size budgeting; the parent overview is Core Concepts of Modern Bundling.
The confusion is structural, not cosmetic. A chunk has three distinct sizes that get used for three different decisions, and treating them as interchangeable is the root of most budgeting mistakes. The raw size is the byte length of the file on disk after minification; it is what the JavaScript engine must parse, compile, and hold in memory, so it governs CPU cost on the device but never touches the network as-is over a modern HTTPS connection. The transfer size is whatever the origin or CDN sends over the wire after content-encoding is applied, and it is almost always smaller because text compresses well. Vite’s build summary prints the raw size in white and a gzip estimate in grey next to it, which is why teams anchor on gzip — but that grey number is Vite’s own gzip at its default level, not necessarily what your host emits, and it never mentions Brotli at all.
The consequence of budgeting the wrong number is silent and expensive. If you set a 200 kB gate against the raw size while your CDN ships Brotli at roughly 58 kB, the gate is loose by a factor of three and never fires when it should. Invert it — budget 60 kB against Brotli while the host has Brotli misconfigured and quietly falls back to gzip at 68 kB — and every deploy trips a gate that describes a number no user ever receives. The fix is to measure the exact encoding your host serves, per chunk, against the exact bytes that ship, and to make that measurement reproducible enough to run in CI. Everything below builds toward that: first the measurement, then the verification that the measurement matches reality, then the edge cases that make the two disagree.
Accept-Encoding decides which body arrives.Prerequisites & reproducible setup
# Vite 5.4.x, Node 20+ (has built-in zlib gzip + brotli)
npm create vite@latest size-demo -- --template react-ts
cd size-demo && npm install
npx vite build # emits dist/assets/*.js
Node’s built-in zlib provides both gzipSync and brotliCompressSync, so no extra dependency is needed to measure. Vite’s build summary shows gzip by default but not Brotli.
Reproducibility matters here more than it looks. The compressed size of a chunk is a deterministic function of three things: the exact input bytes, the compressor, and the compressor’s quality setting. If any of those drift between your local machine and CI, the numbers drift with them, and a size gate that passes locally can fail in CI for reasons that have nothing to do with the code changing. Pinning Vite (5.4.x here) pins minifier behaviour and therefore the input bytes; using Node’s own zlib rather than a third-party wrapper pins the compressor to a version that ships with your Node runtime, so the only remaining variable you have to control is the quality level — which the script sets explicitly. Do the build and the measurement on the same Node major version you run in CI and the two environments will agree to the byte.
One subtlety about the emitted files: vite build writes hashed filenames like index-a1b2c3.js, and the hash is derived from the chunk’s content. That means a byte-identical build produces byte-identical names, which is what makes the report stable across runs, but it also means any content change rotates the filename. The report globs dist/assets/*.js rather than naming files, so it survives those rotations without edits.
node:zlib already computes both sizes.How compression works under the hood
Both gzip and Brotli are lossless text compressors, and understanding why they shrink bundle code by roughly two thirds explains where the numbers come from and why they differ. gzip is DEFLATE: it combines LZ77, which replaces repeated byte sequences with back-references to earlier occurrences, and Huffman coding, which assigns shorter bit patterns to more frequent symbols. Minified JavaScript is unusually compressible for LZ77 because bundlers emit the same tokens over and over — function, return, const, property names, the runtime’s helper identifiers — so the back-reference table stays hot and matches are cheap. gzip’s window is 32 kB, meaning it can only reference repetitions within the preceding 32 kB, which is why a single 400 kB chunk does not compress proportionally better than four 100 kB chunks: the matcher never sees the whole file at once.
Brotli beats gzip on the same input for two concrete reasons. Its sliding window reaches up to 16 MB, so it finds long-range repetition that gzip’s 32 kB window misses entirely — relevant precisely for large vendor chunks where the same library patterns recur far apart. More importantly, Brotli ships a built-in static dictionary of roughly 13,000 common words and substrings drawn from a corpus of web text, including many HTML, CSS, and JavaScript keywords. A chunk gets to reference those dictionary entries without ever having emitted them, so the first occurrence of function or addEventListener is already cheap. That dictionary is why Brotli’s advantage is largest on small-to-medium text files and narrows on large ones, where LZ back-references dominate regardless of the compressor.
Quality is the lever that most affects the number you report. Brotli quality runs from 0 to 11 and gzip level from 0 to 9; higher settings spend more CPU searching for better matches and produce smaller output. The report below uses gzip level 9 and Brotli quality 11 because those are the settings a build-time, cache-once compression step should use — you pay the CPU cost exactly once when the asset is generated, and every subsequent request serves the pre-compressed file. This is the crux of the CDN mismatch discussed later: an edge network that compresses on the fly cannot afford quality 11 per request, so it runs a lower quality and emits a larger body than your local measurement predicts.
Diagnosis workflow
- Know what your host serves. A CDN with Brotli enabled sends Brotli to modern browsers and gzip to the rest; budget against Brotli if that is your primary audience, gzip as the floor. The deciding mechanism is HTTP content negotiation: the browser sends
Accept-Encoding: gzip, deflate, br(and increasinglyzstd), the server intersects that list with the encodings it has available, and it returns the winner in aContent-Encodingresponse header. If your host has pre-compressed.brfiles sitting next to the originals it serves them directly; if it compresses on the fly it does so at whatever quality it is configured for. Either way, the number you should budget is the size of the body carrying theContent-Encodingthe browser actually got, which is why the workflow ends in a real download check rather than trusting the local number alone. - Measure per chunk, not the total. The entry chunk’s compressed size gates first paint; a lazy route’s size only matters when that route loads. Summing every chunk into one total hides exactly the distribution that determines perceived performance: a 250 kB total split as a 60 kB entry plus five 38 kB lazy routes loads fast, while the same 250 kB in one entry chunk blocks first paint on the whole payload. Compression makes this worse to reason about by eye, because two chunks with the same raw size can have different compressed sizes depending on how repetitive their content is, so per-chunk measurement is the only way to see which chunk actually gates which navigation.
- Compress the exact emitted bytes. Measure the built file, not the source — minification and tree-shaking change the input to the compressor. The transform pipeline that runs between your source and the emitted chunk is not size-neutral in either direction: minification strips whitespace and shortens identifiers, which shrinks the raw size but also changes how repetitive the byte stream is, and tree-shaking removes dead exports so the compressor never sees them. Compressing
src/gives a number that corresponds to no artifact anyone ships. Always run the report againstdist/assetsafter a real build.
Annotated solution config
The script reads each .js chunk once, compresses the same buffer with both algorithms, and records all three sizes in a row. Reading the file once and reusing the buffer matters at scale: readFileSync is the expensive I/O step, and passing the same Buffer to both gzipSync and brotliCompressSync avoids a second read and guarantees both compressors see identical input. The sort is by Brotli descending, so the chunk most worth attacking sits at the top of the output regardless of how the filesystem happened to order the directory.
// size-report.mjs — measure raw, gzip, and brotli per chunk (Node 20+).
import { readFileSync, readdirSync } from 'node:fs';
import { gzipSync, brotliCompressSync, constants } from 'node:zlib';
import path from 'node:path';
const dir = 'dist/assets';
const rows = readdirSync(dir)
.filter((f) => f.endsWith('.js'))
.map((f) => {
const bytes = readFileSync(path.join(dir, f));
const gzip = gzipSync(bytes, { level: 9 }).length;
// Brotli quality 11 matches what a well-configured CDN emits.
const brotli = brotliCompressSync(bytes, {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).length;
return { file: f, raw: bytes.length, gzip, brotli };
})
.sort((a, b) => b.brotli - a.brotli);
for (const r of rows) {
const kb = (n) => (n / 1024).toFixed(1) + 'kB';
console.log(`${r.file} raw ${kb(r.raw)} gzip ${kb(r.gzip)} br ${kb(r.brotli)}`);
}
zlib already computes both compressed sizes.The script covers only .js chunks, which is deliberate: JavaScript is where the parse cost and the size battle usually live. If your build ships large CSS or SVG assets you care about, widen the filter and the report generalises without other changes, because zlib compresses any buffer. A common extension is to fail the process with a non-zero exit code when a chunk exceeds a threshold, which turns the same script into a gate. The block below layers a per-chunk Brotli budget on top of the report without touching the measurement logic.
// budget-check.mjs — fail CI if any chunk's Brotli size exceeds its budget (Node 20+).
import { readFileSync, readdirSync } from 'node:fs';
import { brotliCompressSync, constants } from 'node:zlib';
import path from 'node:path';
// Per-chunk Brotli budgets in bytes; the entry is tighter than lazy routes.
const BUDGETS = { 'index': 64 * 1024, default: 50 * 1024 };
const dir = 'dist/assets';
let failed = false;
for (const f of readdirSync(dir).filter((f) => f.endsWith('.js'))) {
const brotli = brotliCompressSync(readFileSync(path.join(dir, f)), {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).length;
const key = f.startsWith('index-') ? 'index' : 'default';
const budget = BUDGETS[key] ?? BUDGETS.default;
if (brotli > budget) {
console.error(`FAIL ${f}: br ${(brotli / 1024).toFixed(1)}kB > ${(budget / 1024).toFixed(0)}kB`);
failed = true;
}
}
process.exit(failed ? 1 : 0);
Verification
# Node 20+ — build then report.
npx vite build
node size-report.mjs
# Example row:
# index-a1b2.js raw 210.4kB gzip 68.1kB br 58.9kB
The report lists every chunk with all three numbers, largest Brotli first. Cross-check one row against curl -H 'Accept-Encoding: br' -so /dev/null -w '%{size_download}' <url> served from your host — the downloaded size should match the Brotli column, confirming the CDN sends what you measured.
The cross-check is the step most people skip, and it is the one that catches a misconfigured host. curl does not request compression unless you ask, so the explicit Accept-Encoding: br header is what forces the origin to negotiate Brotli; without it you download the identity encoding and size_download reports the raw size, which will look alarmingly wrong. -so /dev/null discards the body and silences the progress meter, and -w '%{size_download}' prints the number of bytes actually received over the wire — the transfer size, which is precisely the quantity your budget describes. Run it twice, once with -H 'Accept-Encoding: br' and once with -H 'Accept-Encoding: gzip', and inspect the Content-Encoding response header (add -D - to dump headers) to confirm the host honoured the request rather than silently falling back to identity. If the Brotli download comes back noticeably larger than the report’s Brotli column, the host is compressing at a lower quality than 11 and you have found your mismatch before it reached a budget dashboard.
Gotchas & edge cases
- CDN quality differs. Many CDNs compress at a lower Brotli quality than 11 for speed, so the served body can be larger than your
q11measurement; measure at the quality your host uses. The symptom is a persistent gap between your local Brotli number and thecurldownload, always in the same direction — served larger than measured. The root cause is that on-the-fly edge compression trades ratio for latency: Cloudflare, for instance, historically capped dynamic Brotli around quality 4–5, and Fastly and Nginx defaults sit similarly low. The fix, if you want the measurement to predict reality, is to pass the same quality tobrotliCompressSyncthat your host uses, or better, pre-compress.brfiles at quality 11 during the build and configure the host to serve them statically so it never re-compresses. Confirm by re-running thecurlcheck after the config change; the download should now equal your quality-11 column. - Not every host serves Brotli. If yours only gzips, budget gzip and treat Brotli as informational. The symptom is a
Content-Encoding: gzipresponse even when the request advertisedbr. The root cause is usually that the Brotli module is not compiled in or the static.brfiles are absent, so the host falls back to what it has. The fix depends on your stack — enable the Brotli module, ship pre-compressed assets, or accept gzip as the ceiling — but until Brotli is genuinely served, gzip is the real floor and budgeting the smaller Brotli number sets a target no user will hit. Confirm which encoding ships with the header dump before you trust either column. - Measure the emitted file. Compressing the source, not the built chunk, gives a meaningless number — run the report after
vite build. The symptom is a report that barely changes when you add or remove a dependency, because you are measuring hand-written source that the bundler would have transformed beyond recognition. The root cause is pointing the glob atsrc/or at an un-minified dev build. The fix is to gate the report behind a productionvite buildand globdist/assets. Confirm by deletingdistand re-running the build immediately before the report so there is no stale artifact to measure. - Tiny files can grow. A very small file may be larger compressed; hosts often send those raw, so exclude trivially small assets from Brotli budgets. The symptom is a chunk whose Brotli or gzip column exceeds its raw column by a few bytes. The root cause is fixed compression overhead: gzip adds an 18-byte header-and-trailer frame and Brotli a smaller but non-zero one, and below roughly 100–150 bytes of input that framing outweighs any savings. The fix is that most hosts set a minimum-size threshold and send anything under it as identity encoding, so a sub-kilobyte runtime shim never actually ships compressed. Confirm by checking your host’s minimum-length setting (Nginx’s
gzip_min_length, for example) and excluding assets below it from any Brotli budget so the number reflects what is really sent.
CI integration and performance considerations
The measurement is cheap to run but not free, and where you place it in the pipeline decides whether it protects you or slows you down. Brotli quality 11 is genuinely expensive — compressing a few hundred kilobytes of JavaScript can take a second or more of CPU, and running it across a large dist/ on every keystroke would be intolerable. The right home for the report is after the build step in CI, not in the dev server or a pre-commit hook. Run vite build once, then node budget-check.mjs against the emitted output, and let a non-zero exit fail the job. Because the compression is deterministic given pinned Vite and Node versions, the CI number is authoritative and can be recorded as the baseline that the next PR is diffed against.
Two performance details keep the gate honest. First, cache the build artifacts, not the compression results: the build is the slow, reusable step, while re-running budget-check.mjs on a cached dist/ costs a second and guarantees the numbers reflect the current commit. Second, if you also emit .br files for the host to serve statically, generate them in the same build using a plugin such as vite-plugin-compression at quality 11, and point the report at those exact files rather than compressing again — that way the number you budget and the bytes you ship are literally the same file, and the CDN-quality mismatch disappears by construction. Treat the on-the-fly path as a fallback for anything the static path missed, and measure that fallback at its real quality so the report never promises a smaller body than the edge can deliver.
Related
- Bundle analysis and size budgeting — the parent cluster on attribution and budgets.
- Enforcing per-route size budgets in CI — budgeting the compressed numbers this report produces.
- Reading a bundle with source-map-explorer — attributing the raw bytes that compression then shrinks.