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.

Raw shrinks to gzip, and further to Brotli, on the wire A chunk's raw size is what parses on the device, gzip is a smaller transfer size most servers send, and Brotli is smaller still where the client advertises support; the client's Accept-Encoding decides which body it receives. Three sizes for one chunk — budget the one that ships raw parse/compile cost gzip common transfer brotli smallest Accept-Encodingclient picks
Figure: raw drives CPU, gzip and Brotli drive transfer — and the client's 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's zlib provides both compressors with no dependency Node's built-in zlib module exposes gzipSync and brotliCompressSync, so the emitted chunks from a vite build can be measured for both gzip and brotli size without installing any additional package. dist/assets/*.jsemitted chunks node:zlibgzipSync + brotli no installbuilt in everything you need ships with Node
Figure: no analyzer dependency is required — 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

  1. 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 increasingly zstd), the server intersects that list with the encodings it has available, and it returns the winner in a Content-Encoding response header. If your host has pre-compressed .br files 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 the Content-Encoding the browser actually got, which is why the workflow ends in a real download check rather than trusting the local number alone.
  2. 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.
  3. 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 against dist/assets after a real build.
Budget against what the host actually serves If the CDN serves Brotli to modern clients, budget the entry chunk against its Brotli size with gzip as the floor; measure each chunk's compressed size separately because the entry gates first paint while lazy routes only cost when loaded. CDN serves Brotlibudget Brotligzip as floor gzip-only hostbudget gzipBrotli is informational the host's encoding decides the number that counts
Figure: budget the encoding your host serves — measuring Brotli is pointless if the CDN only sends gzip.

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)}`);
}
One pass emits three numbers per chunk The report reads each emitted chunk once and computes raw length, gzip length at level 9, and brotli length at quality 11, then sorts by brotli so the largest compressed chunk is listed first. read chunk raw length gzip level 9 brotli q11 sorted reportby brotli desc
Figure: no dependency needed — Node's 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.

Cross-check the report against a real download A curl request with Accept-Encoding br downloads the Brotli body and its size should equal the Brotli column of the report, confirming the numbers you budget against are the bytes the host actually sends. report brotli columnmeasured locally curl size_downloadserved by host the measured and served sizes should agree
Figure: matching the local Brotli number to a real download proves the host serves what you budgeted.

Gotchas & edge cases

Four compression-measurement edge cases A CDN may compress at a lower quality than your q11 measurement so the served size is larger; some hosts do not serve Brotli so gzip is the real floor; measuring source instead of the emitted file gives a wrong number; and tiny files can grow when compressed so they are sometimes sent raw. CDN quality < q11→ served size larger than measured host has no Brotli→ gzip is the real floor measuring source→ compress the emitted file tiny file grows→ may be sent raw
Figure: a CDN compressing below q11 is the common surprise — the served body is bigger than your local measurement.
  • CDN quality differs. Many CDNs compress at a lower Brotli quality than 11 for speed, so the served body can be larger than your q11 measurement; measure at the quality your host uses. The symptom is a persistent gap between your local Brotli number and the curl download, 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 to brotliCompressSync that your host uses, or better, pre-compress .br files at quality 11 during the build and configure the host to serve them statically so it never re-compresses. Confirm by re-running the curl check 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: gzip response even when the request advertised br. The root cause is usually that the Brotli module is not compiled in or the static .br files 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 at src/ or at an un-minified dev build. The fix is to gate the report behind a production vite build and glob dist/assets. Confirm by deleting dist and 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.