Measure Brotli and Gzip on the Assets You Actually Serve
Last edited on August 14, 2026

On one 730,410-byte HTML, CSS, and JavaScript corpus, Brotli level 6 produced an 8,502-byte file in a median 3.936 ms. Gzip level 6 produced 25,138 bytes in 4.109 ms. Brotli level 11 squeezed the same source to 5,400 bytes—but took 861.062 ms to encode.

Those measurements do not crown one universal winner. They expose the decision that a generic “Brotli is smaller” statement hides: wire bytes and encoder work must be measured separately on the content you actually serve. A precompressed build can spend more CPU once; a dynamic origin pays again for changing responses, cache misses, and variants.

This practical guide is for a developer who can run Bash and Node.js on a disposable shell. It builds a deterministic text corpus, compresses identical bytes five ways, verifies every decoded SHA-256 hash, and serves Brotli, gzip, and identity variants from a loopback HTTP server. Nothing changes a production web server, CDN, DNS record, or application.

Read the Two-Axis Result Before Choosing a Codec

Compression ratio answers how many bytes cross the network. Encoder time answers how much work happens before those bytes can leave. Neither measurement alone predicts page speed: latency, cache reuse, request concurrency, HTML generation, browser parsing, transport setup, and critical-resource order still matter. The separate strict HTTP/3 path test is a useful reminder that content encoding and transport protocol are different layers.

Here is the reproduced five-run median from Node.js 24.18.0, Brotli 1.2.0, and zlib 1.3.1-e00f703 on Linux 6.12.96. “Percent of source” is compressed bytes divided by 730,410; lower is smaller. Timing includes only the in-memory encoder call, not file reads, TLS, network transfer, or cache behavior.

Codec and level Compressed bytes Percent of source Median encode time Decoded hash
gzip 6 25,138 3.44% 4.109 ms match
gzip 9 18,722 2.56% 16.967 ms match
Brotli 4 22,967 3.14% 2.573 ms match
Brotli 6 8,502 1.16% 3.936 ms match
Brotli 11 5,400 0.74% 861.062 ms match

Because this corpus deliberately repeats markup, CSS selectors, and JavaScript shapes, it compresses far more than a typical mixed page transfer. Its absolute ratio is not portable. The useful result is the method and the shape of the local tradeoff: level 11 saved another 3,102 bytes over Brotli 6 while consuming roughly 219 times the measured encoder time in this run.

Cloudflare’s dynamic Brotli experiment found the same broad quality-level tension across a much larger file set: moderate levels delivered useful gains, while the highest levels demanded substantially more processing. Paul Calvano’s compression tester and decision analysis likewise keeps byte savings and latency visible together. Their numbers are benchmark context; the release decision should come from your assets, implementation, host, and cache path.

Own the Corpus and the Cleanup Boundary

Start with a marker-owned directory. The cleanup function refuses an unexpected path and removes only this lab. It also stops the loopback server if a later assertion fails. Run every block in the same Bash session.

set -Eeuo pipefail
LAB_DIR="$(mktemp -d /tmp/voxfor-brotli-gzip-177.XXXXXX)"
MARKER="$LAB_DIR/.voxfor-owned"
CORPUS="$LAB_DIR/corpus.txt"
RECEIPT="$LAB_DIR/receipt.txt"
RECEIPT_COPY="$PWD/brotli-gzip-receipt-177.txt"
SERVER_PID=""
cleanup() {
  if [[ -n "${SERVER_PID:-}" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
    kill "$SERVER_PID"; wait "$SERVER_PID" 2>/dev/null || true
  fi
  if [[ -d "${LAB_DIR:-}" && -f "${MARKER:-}" ]] \
     && [[ "$(<"$MARKER")" == "voxfor-brotli-gzip-177" ]] \
     && [[ "$LAB_DIR" == /tmp/voxfor-brotli-gzip-177.* ]]; then
    find "$LAB_DIR" -depth -mindepth 1 -delete
    rmdir "$LAB_DIR"
  fi
}
trap cleanup EXIT
for command_name in node gzip curl sha256sum awk grep stat; do
  command -v "$command_name" >/dev/null
done
[[ ! -e "$RECEIPT_COPY" ]]
printf '%s\n' 'voxfor-brotli-gzip-177' > "$MARKER"
printf 'environment\tnode=%s\tbrotli=%s\tzlib=%s\tgzip=%s\tcurl=%s\tkernel=%s\n' \
  "$(node -p 'process.versions.node')" \
  "$(node -p 'process.versions.brotli')" \
  "$(node -p 'process.versions.zlib')" \
  "$(gzip --version | sed -n '1s/^gzip //p')" \
  "$(curl --version | awk 'NR==1 {print $2}')" \
  "$(uname -r)" | tee "$RECEIPT"

Replace the synthetic generator with a representative, secret-free production sample before making a real policy decision. Include the text assets that dominate first-load and cache-miss bytes: rendered HTML states, route-specific JavaScript, CSS bundles, JSON or SVG when applicable. Exclude passwords, customer data, tokens, private source maps, and already compressed binaries.

For a safe worked example, the generator below uses fixed line counts and modulo patterns. Its 730,410 source bytes remain reproducible without downloading a framework or copying a real website.

cat > "$LAB_DIR/build-corpus.mjs" <<'JS'
import fs from 'node:fs';
const target = process.argv[2];
const html = Array.from({length: 2400}, (_, i) =>
  `<article data-id="${i % 120}"><h2>Compression receipt ${i % 40}</h2><p>Measure HTML CSS JavaScript bytes before changing delivery policy.</p></article>`
).join('\n');
const css = Array.from({length: 1800}, (_, i) =>
  `.metric-${i % 90}{display:grid;grid-template-columns:repeat(${2 + (i % 3)},1fr);gap:${8 + (i % 5)}px;color:#${(i % 16).toString(16).repeat(6)}}`
).join('\n');
const js = Array.from({length: 2200}, (_, i) =>
  `export function metric${i % 110}(rows){return rows.filter(row=>row.group===${i % 17}).map(row=>({id:row.id,value:row.value*${1 + (i % 7)}}));}`
).join('\n');
fs.writeFileSync(target, `${html}\n/* CSS */\n${css}\n/* JavaScript */\n${js}\n`);
JS
node "$LAB_DIR/build-corpus.mjs" "$CORPUS"
printf 'corpus\tbytes=%s\tsha256=%s\tlines=%s\n' \
  "$(stat -c %s "$CORPUS")" \
  "$(sha256sum "$CORPUS" | awk '{print $1}')" \
  "$(wc -l < "$CORPUS")" | tee -a "$RECEIPT"

For an existing origin, collect both source size and actual transferred bytes. NGINX bandwidth forecasting shows why edge, origin, and application scopes must be named rather than merged into one byte total. A lab corpus helps choose a candidate; production telemetry shows whether that candidate affects the bill and user path you care about.

Compress Identical Bytes Five Ways

Node’s current zlib API exposes gzip and Brotli encoders through one runtime. That removes process-startup differences between the five cases, although it does not make timing universal across Node, library, CPU, thermal, or scheduler versions.

Each case runs five times, sorts the durations, and records the middle sample. More rigorous capacity work should use a longer warmed benchmark, controlled CPU placement, several asset classes, concurrency, and tail latency. Five repetitions are enough here to expose a level-11 cost control without pretending to size a production fleet.

cat > "$LAB_DIR/benchmark.mjs" <<'JS'
import fs from 'node:fs';
import crypto from 'node:crypto';
import zlib from 'node:zlib';
import { performance } from 'node:perf_hooks';
const [sourcePath, outputDir, receiptPath] = process.argv.slice(2);
const source = fs.readFileSync(sourcePath);
const sourceHash = crypto.createHash('sha256').update(source).digest('hex');
const cases = [
  {id:'gzip-6', ext:'gz', encode:b=>zlib.gzipSync(b,{level:6}), decode:zlib.gunzipSync},
  {id:'gzip-9', ext:'gz9', encode:b=>zlib.gzipSync(b,{level:9}), decode:zlib.gunzipSync},
  {id:'br-4', ext:'br4', encode:b=>zlib.brotliCompressSync(b,{params:{[zlib.constants.BROTLI_PARAM_QUALITY]:4}}), decode:zlib.brotliDecompressSync},
  {id:'br-6', ext:'br', encode:b=>zlib.brotliCompressSync(b,{params:{[zlib.constants.BROTLI_PARAM_QUALITY]:6}}), decode:zlib.brotliDecompressSync},
  {id:'br-11', ext:'br11', encode:b=>zlib.brotliCompressSync(b,{params:{[zlib.constants.BROTLI_PARAM_QUALITY]:11}}), decode:zlib.brotliDecompressSync}
];
const rows = [];
for (const test of cases) {
  const samples = [];
  let encoded;
  for (let i = 0; i < 5; i++) {
    const start = performance.now();
    encoded = test.encode(source);
    samples.push(performance.now() - start);
  }
  samples.sort((a,b) => a-b);
  const decodedHash = crypto.createHash('sha256').update(test.decode(encoded)).digest('hex');
  if (decodedHash !== sourceHash) throw new Error(`${test.id} decoded hash mismatch`);
  fs.writeFileSync(`${outputDir}/corpus.${test.ext}`, encoded);
  rows.push({id:test.id, bytes:encoded.length, medianMs:samples[2], decodedHash});
}
const byId = Object.fromEntries(rows.map(row => [row.id, row]));
if (!(byId['br-6'].bytes < byId['gzip-6'].bytes)) throw new Error('br-6 was not smaller than gzip-6 on this corpus');
if (!(byId['br-11'].bytes <= byId['br-6'].bytes)) throw new Error('br-11 was not at least as small as br-6');
if (!(byId['br-11'].medianMs > byId['br-6'].medianMs)) throw new Error('br-11 did not expose higher measured encoder cost');
const lines = rows.map(row =>
  `codec=${row.id}\tbytes=${row.bytes}\tpercent_of_source=${(100*row.bytes/source.length).toFixed(2)}\tmedian_ms=${row.medianMs.toFixed(3)}\tdecoded_sha256=${row.decodedHash}`
);
fs.appendFileSync(receiptPath, `${lines.join('\n')}\nbenchmark_assertions=PASS\tall_decoded_hashes_match=yes\tbr6_smaller_than_gzip6=yes\tbr11_cost_control=yes\n`);
console.log(lines.join('\n'));
JS
node "$LAB_DIR/benchmark.mjs" "$CORPUS" "$LAB_DIR" "$RECEIPT"

Article-specific assertions prove that all five outputs reverse to the exact source and that this corpus contains the expected decision contrast. They do not instruct your deployment to require Brotli-6 to beat gzip-6 by a fixed percentage. A different site may contain minified code, unique JSON, localized HTML, or small files whose header overhead and dictionaries change the result.

An independent file-level check now decodes every artifact again. GNU gzip validates the gzip files; Node’s Brotli decoder validates the three Brotli files. This catches a saved-artifact or extension mix-up after the timed encoder call.

SOURCE_SHA="$(sha256sum "$CORPUS" | awk '{print $1}')"
for gzip_file in "$LAB_DIR/corpus.gz" "$LAB_DIR/corpus.gz9"; do
  [[ "$(gzip -dc "$gzip_file" | sha256sum | awk '{print $1}')" == "$SOURCE_SHA" ]]
done
for brotli_file in "$LAB_DIR/corpus.br4" "$LAB_DIR/corpus.br" "$LAB_DIR/corpus.br11"; do
  [[ "$(node -e 'const fs=require("node:fs"),z=require("node:zlib");process.stdout.write(z.brotliDecompressSync(fs.readFileSync(process.argv[1])))' "$brotli_file" | sha256sum | awk '{print $1}')" == "$SOURCE_SHA" ]]
done
printf 'saved_artifacts\tdecoded_sha256=%s\tfiles_verified=5\n' "$SOURCE_SHA" | tee -a "$RECEIPT"

When dynamic compression runs inside the request path, measure request latency and CPU saturation under concurrency rather than multiplying the single-thread median. NGINX request and upstream timing helps separate application time from work added around the upstream response, but CPU profiles and encoder metrics are still needed to attribute compression cost.

Prove HTTP Negotiation and Wire-Body Identity

A .br file on disk proves only that an encoder created a file. A Content-Encoding: br header proves only what a response claims. HTTP correctness requires the representation selected for the request to decode to the source, with fallback behavior and cache variance preserved.

RFC 9110 defines Accept-Encoding as the client’s acceptable content codings and Content-Encoding as metadata describing codings applied to the representation. The server below prefers Brotli when offered, otherwise gzip, otherwise identity. It always emits Vary: Accept-Encoding so a cache knows that the request field can change the selected representation.

cat > "$LAB_DIR/server.mjs" <<'JS'
import http from 'node:http';
import fs from 'node:fs';
const root = process.argv[2];
const server = http.createServer((request, response) => {
  const accepted = request.headers['accept-encoding'] || '';
  let file = `${root}/corpus.txt`;
  let encoding = '';
  if (/(^|,|\s)br(\s|,|$)/i.test(accepted)) {
    file = `${root}/corpus.br`; encoding = 'br';
  } else if (/(^|,|\s)gzip(\s|,|$)/i.test(accepted)) {
    file = `${root}/corpus.gz`; encoding = 'gzip';
  }
  response.statusCode = 200;
  response.setHeader('Content-Type', 'text/plain; charset=utf-8');
  response.setHeader('Vary', 'Accept-Encoding');
  if (encoding) response.setHeader('Content-Encoding', encoding);
  response.setHeader('Content-Length', fs.statSync(file).size);
  fs.createReadStream(file).pipe(response);
});
server.listen(0, '127.0.0.1', () => {
  fs.writeFileSync(`${root}/port`, String(server.address().port));
});
JS
node "$LAB_DIR/server.mjs" "$LAB_DIR" &
SERVER_PID=$!
for _ in {1..100}; do [[ -s "$LAB_DIR/port" ]] && break; sleep 0.05; done
PORT="$(<"$LAB_DIR/port")"
[[ "$PORT" =~ ^[0-9]+$ ]]

With --raw, the three requests save encoded bodies without transfer decoding hiding the on-wire representation. Everything curl’s content-encoding reference explains the different convenience path: --compressed advertises curl-supported encodings and automatically decodes the response. Automatic decoding is useful for application checks; raw bodies are needed for this byte and hash receipt.

curl -fsS --raw -D "$LAB_DIR/br.headers" -H 'Accept-Encoding: br,gzip' \
  "http://127.0.0.1:$PORT/asset" -o "$LAB_DIR/wire.br"
curl -fsS --raw -D "$LAB_DIR/gzip.headers" -H 'Accept-Encoding: gzip' \
  "http://127.0.0.1:$PORT/asset" -o "$LAB_DIR/wire.gz"
curl -fsS --raw -D "$LAB_DIR/identity.headers" -H 'Accept-Encoding: identity' \
  "http://127.0.0.1:$PORT/asset" -o "$LAB_DIR/wire.txt"
grep -qi $'^Content-Encoding: br\r$' "$LAB_DIR/br.headers"
grep -qi $'^Content-Encoding: gzip\r$' "$LAB_DIR/gzip.headers"
grep -qi $'^Vary: Accept-Encoding\r$' "$LAB_DIR/br.headers"
! grep -qi '^Content-Encoding:' "$LAB_DIR/identity.headers"

Decode the two compressed wire bodies, compare all three hashes with the source, and record the transferred byte counts. This is the point where a header claim becomes a verified representation contract.

node - "$LAB_DIR/wire.br" "$LAB_DIR/br.decoded" <<'JS'
const fs = require('node:fs');
const zlib = require('node:zlib');
fs.writeFileSync(process.argv[3], zlib.brotliDecompressSync(fs.readFileSync(process.argv[2])));
JS
gzip -dc "$LAB_DIR/wire.gz" > "$LAB_DIR/gzip.decoded"
[[ "$(sha256sum "$LAB_DIR/br.decoded" | awk '{print $1}')" == "$SOURCE_SHA" ]]
[[ "$(sha256sum "$LAB_DIR/gzip.decoded" | awk '{print $1}')" == "$SOURCE_SHA" ]]
[[ "$(sha256sum "$LAB_DIR/wire.txt" | awk '{print $1}')" == "$SOURCE_SHA" ]]
printf 'http_negotiation\tbr_bytes=%s\tgzip_bytes=%s\tidentity_bytes=%s\tvary=Accept-Encoding\tdecoded_sha256=%s\n' \
  "$(stat -c %s "$LAB_DIR/wire.br")" \
  "$(stat -c %s "$LAB_DIR/wire.gz")" \
  "$(stat -c %s "$LAB_DIR/wire.txt")" \
  "$SOURCE_SHA" | tee -a "$RECEIPT"
printf 'receipt\tcompression_decodes_match=yes\tbr_preferred=yes\tgzip_fallback=yes\tidentity_fallback=yes\trollback_scope=marker-owned\n' | tee -a "$RECEIPT"

Representative output from the complete reproduction follows. Encoder timings naturally vary; the byte counts and SHA-256 values are deterministic for the declared runtime and corpus.

environment node=24.18.0 brotli=1.2.0 zlib=1.3.1-e00f703 gzip=1.13 curl=8.14.1 kernel=6.12.96+deb13-amd64
corpus bytes=730410 sha256=57bb615ef7e111fe254def7ca1c042550177d9dcca54915fd54368e42c7c51f8 lines=6402
codec=gzip-6 bytes=25138 percent_of_source=3.44 median_ms=4.109 decoded_sha256=57bb615ef7e111fe254def7ca1c042550177d9dcca54915fd54368e42c7c51f8
codec=gzip-9 bytes=18722 percent_of_source=2.56 median_ms=16.967 decoded_sha256=57bb615ef7e111fe254def7ca1c042550177d9dcca54915fd54368e42c7c51f8
codec=br-4 bytes=22967 percent_of_source=3.14 median_ms=2.573 decoded_sha256=57bb615ef7e111fe254def7ca1c042550177d9dcca54915fd54368e42c7c51f8
codec=br-6 bytes=8502 percent_of_source=1.16 median_ms=3.936 decoded_sha256=57bb615ef7e111fe254def7ca1c042550177d9dcca54915fd54368e42c7c51f8
codec=br-11 bytes=5400 percent_of_source=0.74 median_ms=861.062 decoded_sha256=57bb615ef7e111fe254def7ca1c042550177d9dcca54915fd54368e42c7c51f8
benchmark_assertions=PASS all_decoded_hashes_match=yes br6_smaller_than_gzip6=yes br11_cost_control=yes
saved_artifacts decoded_sha256=57bb615ef7e111fe254def7ca1c042550177d9dcca54915fd54368e42c7c51f8 files_verified=5
http_negotiation br_bytes=8502 gzip_bytes=25138 identity_bytes=730410 vary=Accept-Encoding decoded_sha256=57bb615ef7e111fe254def7ca1c042550177d9dcca54915fd54368e42c7c51f8
receipt compression_decodes_match=yes br_preferred=yes gzip_fallback=yes identity_fallback=yes rollback_scope=marker-owned
cleanup lab_absent=yes

At a CDN, Vary is only one input to the cache policy. Confirm the provider’s actual compression and cache-key behavior rather than assuming every intermediary stores variants identically. Review CDN cache-key boundaries when representation choice, cookies, authentication, or tenant data could alter safe storage.

Turn the Receipt Into a Delivery Policy

Depending on where compression runs, the measured result supports three different actions:

  • Precompressed static assets: generate .br and .gz files in CI or a build step, verify their decoded hashes, publish them beside the source, and let a tested server or CDN negotiate. Level 11 may be reasonable when builds are infrequent and its extra build time stays inside the release budget.
  • Dynamic HTML or API responses: start with a moderate quality, load-test representative responses, and watch CPU, p95/p99 request latency, cache-hit rate, and fallback bytes. Do not move a level-11 build choice into the request path without new evidence.
  • Edge-managed compression: measure the public edge response and the bypassed origin separately. A public br header can describe edge work even when the origin serves identity or gzip.

Linux Audit’s precompressed Brotli check is a useful minimal confirmation, but production acceptance should test GET bodies rather than HEAD alone, exercise every required fallback, and compare decoded content. Also test small resources: compression overhead or minimum-size thresholds can make a policy skip them legitimately.

Run the receipt under representative concurrency on VPS infrastructure where you control the origin stack before changing the server configuration.

Preserve the receipt, then remove only the marked workspace with the final lab input.

cp "$RECEIPT" "$RECEIPT_COPY"
cleanup
trap - EXIT
[[ ! -e "$LAB_DIR" ]]
printf 'cleanup\tlab_absent=yes\n' | tee -a "$RECEIPT_COPY"

Accept the lab when every encoder reverses to the source SHA-256, the saved files pass the second decode check, Brotli-6 and gzip-6 expose a measurable same-corpus choice, level 11 supplies a clearly costlier control, the loopback server returns br/gzip/identity exactly as requested, Vary: Accept-Encoding is present, all three wire bodies decode to the source, and marker-owned cleanup leaves the saved receipt outside the removed directory.

If any hash, header, fallback, timing assertion, or load-test boundary fails, keep production unchanged, stop the loopback process, let the ownership-checked trap remove only the lab, and preserve the receipt for diagnosis. For a live rollout, restore the backed-up origin or CDN compression policy, purge only affected representation variants, request br, gzip, and identity again, and require the previous known-good decoded body hash before closing rollback.

Brotli and Gzip Measurement Questions

Is Brotli always smaller than gzip?

No. Brotli often compresses web text more tightly at comparable settings, but file size, content structure, quality level, encoder implementation, and dictionary behavior matter. Test the exact assets and record both source and compressed bytes instead of importing a percentage from another site.

Which Brotli quality should dynamic responses use?

There is no universal level. Begin with a moderate candidate, then compare response bytes, encoder CPU, and tail latency under representative concurrency. Very high levels are usually easier to justify in a build step where compression happens once and the artifact is reused.

Why hash the decoded output?

Compressed bytes are intentionally different from the source, so their direct hash cannot prove content identity. Decode each candidate and hash the result; a matching source SHA-256 proves the tested encoder/decoder path reproduced the exact bytes.

Does Content-Encoding: br prove the origin uses Brotli?

No. A CDN or reverse proxy can compress an identity or gzip origin response. Test the edge and a controlled origin path separately, then label each receipt with the hostname, resolved endpoint, cache state, request headers, response coding, and decoded hash.

Should JPEG, PNG, WebP, video, or archives use this text-compression policy?

Usually not. Those formats already contain compression, and another HTTP content coding may spend CPU for negligible savings. Measure them if a specific platform requires it, but keep the default corpus focused on compressible text such as HTML, CSS, JavaScript, JSON, XML, and SVG.

Can a site remove gzip fallback after enabling Brotli?

Only after evidence from the supported clients and intermediaries in your own delivery path permits that change. Keep gzip and identity acceptance paths until measured compatibility data and the service contract justify removing one.

Keep the Measurement Beside the Deployment

Policy choice should not stop at the row with the fewest bytes. A sound result reproduces exact content, fits the build or request CPU budget, negotiates every supported client path, and still passes under representative traffic. Keep that receipt beside the deployment so the next library, CDN, asset, or workload change can be measured against the same contract.

Share this Post

Leave a Reply

Your email address will not be published. Required fields are marked *