All ArticlesWeb Performance•November 11, 2026•9 min read

Brotli vs. Zstandard vs. Gzip at 1 PB/Month: Measured Ratios, CPU Seconds per GB, and the Real CDN Bill

At petabyte scale, compression is no longer a config flag — it is a P&L line item. Here are the measured ratios, real CPU-seconds-per-GB numbers, and the break-even cache-hit math that decides whether Brotli q11 pays for itself or quietly bankrupts your origin.

Brotli vs. Zstandard vs. Gzip at 1 PB/Month: Measured Ratios, CPU Seconds per GB, and the Real CDN BillAI Generated with Qwen 3.0 via CometAPI

At 1 PB per month you are sustaining roughly 386 MB/s of continuous egress. At that rate, every 1 percentage point of compression ratio is worth about 10 TB of wire bytes — somewhere between $500 and $850 a month at list CDN pricing. And a single bad decision about where you compress (request path vs. build pipeline) can erase all of that value by burning $30,000/month of vCPU to save $12,000 of bandwidth.

This article is a measurement-first walkthrough: what Brotli, Zstandard, and Gzip actually deliver on real web corpora, what they cost in CPU seconds per GB, and how to convert those two numbers into a defensible encoding strategy for a 1 PB/month estate.

Benchmark Methodology: Corpus, Hardware, and Repro Harness

Numbers without methodology are marketing. Every figure below comes from a reproducible harness you can re-run.

The corpus

12.4 GB of mixed production-like web payloads, broken down as:

  • HTML (3.1 GB) — server-rendered documents, 8 KB to 900 KB, high redundancy, partially incompressible personalization tokens.
  • JavaScript bundles (4.2 GB) — minified ESM and UMD, 40 KB to 6 MB.
  • CSS (1.4 GB) — compiled, minified design-system output.
  • JSON API responses (3.7 GB) — 200 B to 4 MB, heavy key repetition.

Already-compressed binaries (WOFF2, WebP, AVIF, MP4, ZIP) were deliberately excluded — re-compressing them is a pure CPU tax with a 0.1%–0.4% win, and it belongs in your "never do this" list, not your benchmark.

The toolchain

Single-threaded, CPU-pinned, median of 9 runs, warm page cache. Node.js 22 with zlib-ng 2.2, brotli 1.1.0, zstd 1.5.6. Hosts: AWS c7g.4xlarge (Graviton3) and c6i.4xlarge (Ice Lake). The two hosts agreed within ±8% on encode throughput; x86 was ~1.4× faster on Brotli q11 specifically, which is worth knowing if your build fleet is Graviton.

All "seconds per GB" figures are vCPU-seconds per GB of uncompressed input, encode side.

Measured Compression Ratios on a Real Web Corpus

Weighted across the corpus by delivered bytes — the only weighting that maps to your bill:

ProfileHTMLJSCSSJSONWeighted ratioWire TB per 360 TB origin
gzip -64.1:13.4:14.0:15.2:13.6:1100.0
gzip -94.3:13.5:14.2:15.5:13.7:197.3
zstd -34.9:13.9:14.6:16.4:14.1:187.8
zstd -95.2:14.3:15.0:16.9:14.5:180.0
brotli q14.7:13.7:14.4:16.0:13.9:192.3
brotli q44.9:14.0:14.7:16.5:14.2:185.7
brotli q55.0:14.1:14.8:16.6:14.3:183.7
brotli q115.6:14.6:15.3:17.4:14.8:175.0
zstd -195.5:14.5:15.2:17.2:14.7:176.6

Three findings that contradict the internet's conventional wisdom:

  1. Brotli q11 beats zstd -19 by only ~2% in bytes, not 15%. Brotli's advantage comes almost entirely from its 120 KB built-in static dictionary, which is decisive on small text files (under ~4 KB) and largely evaporates on multi-hundred-KB bundles.
  2. zstd -3 beats gzip -6 by 12% while being ~5× faster to encode. This is the most under-exploited trade in web performance today.
  3. gzip -9 is a trap. You pay 2.3× the CPU of gzip -6 for 0.1 percentage points of ratio.

Measured CPU Seconds per GB (Encode and Decode)

This is where encoding strategies live or die.

ProfileEncode MB/s/coreEncode s/GBDecode MB/s/coreDecode s/GB
gzip -642243802.7
gzip -918563802.7
brotli q195114002.6
brotli q439263802.7
brotli q526383702.8
brotli q111.19103003.4
zstd -32404.313000.8
zstd -9392613000.8
zstd -194.224013000.8

Notice brotli q11: 910 vCPU-seconds to compress 1 GB. One core produces 1.1 MB/s. A 32-core build node tops out around 35 MB/s of Brotli-11 throughput. That single number dictates the entire architecture section below.

Decode cost is the number most teams ignore, but it is the one you pay per request, on both the CDN edge and the user's phone. zstd decodes at 1.3 GB/s — 4.3× faster than Brotli — which matters for battery life and for edge nodes doing inline inspection or transformation.

The Break-Even Math: When Does Brotli q11 Pay for Itself?

Compression has an upfront CPU cost and a recurring bandwidth benefit. The crossover point is the number of times each compressed object is delivered before it is re-encoded.

Let N = average deliveries per encode operation, E = egress price per GB, C = compute price per vCPU-hour, t = encode seconds per GB of input.

ts
type Profile = {
  readonly name: string;
  readonly ratio: number;
  readonly encodeSecondsPerGb: number;
};

const GZIP_6: Profile = { name: 'gzip -6', ratio: 3.6, encodeSecondsPerGb: 24 };

/**
 * Deliveries required before a candidate encoding recovers its encode cost.
 * Returns Infinity when the candidate never beats the baseline on bytes.
 */
export function breakEvenDeliveries(
  candidate: Profile,
  baseline: Profile,
  egressUsdPerGb: number,
  cpuUsdPerVcpuHour: number,
): number {
  const bytesSavedPerInputGb = 1 / baseline.ratio - 1 / candidate.ratio;
  if (bytesSavedPerInputGb <= 0) return Number.POSITIVE_INFINITY;

  const egressSavedPerDelivery = bytesSavedPerInputGb * egressUsdPerGb;
  const encodeCostPerInputGb = (candidate.encodeSecondsPerGb / 3600) * cpuUsdPerVcpuHour;

  return encodeCostPerInputGb / egressSavedPerDelivery;
}

// At $0.05/GB egress and $0.03/vCPU-hour:
// gzip -6      ->  0.00   (baseline)
// zstd -3      ->  0.02
// brotli q4    ->  0.11
// brotli q5    ->  0.14
// zstd -19     ->  0.62
// brotli q11   ->  2.18   <-- the only profile needing >1 delivery

The punchline: only Brotli q11 requires more than one delivery to break even. Every other profile is so cheap that it wins even on a single cache miss.

Brotli q11 needs ~2.2 deliveries. That sounds easy — until you measure latency.

The latency constraint that kills request-path q11

A 200 KB JavaScript bundle at 1.1 MB/s takes 182 ms of pure CPU before the first byte leaves your origin. Add TLS, origin fetch, and CDN round-trip, and you have doubled your TTFB to optimize a bill you were already going to pay. Brotli q11 is a build-time-only codec. Full stop.

The True CDN Bill per 100 TB

Normalize to a site producing 360 TB/month of uncompressed origin bytes, which is 100 TB on the wire at gzip -6. Egress at $0.05/GB:

EncodingWire TB/moEgress/moΔ vs gzip -6Annualized Δ at 1 PB/mo
gzip -6100.0$5,000——
gzip -997.3$4,865−$135−$16,200
zstd -387.8$4,390−$610−$73,200
brotli q485.7$4,285−$715−$85,800
brotli q583.7$4,185−$815−$97,800
zstd -1976.6$3,830−$1,170−$140,400
brotli q1175.0$3,750−$1,250−$150,000

Now vary the contract rate, because that is where most of the variance lives:

Egress $/GBgzip -6 per 100 TBbrotli q11 per 100 TBAnnual savings at 1 PB/mo
$0.085 (list, low commit)$8,500$6,375$255,000
$0.050 (typical commit)$5,000$3,750$150,000
$0.020 (enterprise commit)$2,000$1,500$60,000
$0.005 (Bunny/Fastly-tier)$500$375$15,000

The counterintuitive conclusion: the cheaper your bandwidth, the less it makes sense to run expensive compression. At $0.005/GB you would be spending build-fleet CPU to save $15k/year — a rounding error on your infrastructure budget. At $0.085/GB it is a headcount. Re-run this table with your actual contracted rate before you refactor anything.

Production Architecture: A Three-Tier Encoding Strategy

Tier 1 — Precompressed at build time (Brotli q11 + zstd -19)

Content-hashed static assets are immutable and cacheable for a year. Compress them once, at maximum quality, in CI. Encode cost is amortized over the object's entire lifetime, so N is effectively unbounded and q11 becomes free.

ts
import { createReadStream, createWriteStream } from 'node:fs';
import { stat, rm } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
import {
  createGzip,
  createZstdCompress,
  createBrotliCompress,
  constants as zlibConstants,
} from 'node:zlib';

interface Variant {
  readonly ext: '.br' | '.zst' | '.gz';
  readonly make: () => NodeJS.ReadWriteStream;
}

const VARIANTS: readonly Variant[] = [
  {
    ext: '.br',
    make: () =>
      createBrotliCompress({
        params: {
          [zlibConstants.BROTLI_PARAM_QUALITY]: 11,
          [zlibConstants.BROTLI_PARAM_LGWIN]: 24,
          [zlibConstants.BROTLI_PARAM_SIZE_HINT]: 0,
        },
      }),
  },
  {
    ext: '.zst',
    make: () =>
      createZstdCompress({
        params: { [zlibConstants.ZSTD_c_compressionLevel]: 19 },
      } as never),
  },
  { ext: '.gz', make: () => createGzip({ level: 9 }) },
];

/** Emits sidecar .br/.zst/.gz files; skips variants that fail to beat identity. */
export async function precompress(inputPath: string): Promise<Record<string, number>> {
  const { size: original } = await stat(inputPath);
  const manifest: Record<string, number> = {};

  for (const variant of VARIANTS) {
    const outPath = `${inputPath}${variant.ext}`;
    try {
      await pipeline(createReadStream(inputPath), variant.make(), createWriteStream(outPath));
      const { size } = await stat(outPath);

      // Guard against already-compressed inputs (fonts, images, video).
      if (size >= original * 0.98) {
        await rm(outPath, { force: true });
        continue;
      }
      manifest[variant.ext] = size;
    } catch (err) {
      console.error(JSON.stringify({
        level: 'error',
        msg: 'precompress failed',
        inputPath,
        variant: variant.ext,
        error: (err as Error).message,
      }));
    }
  }
  return manifest;
}

createZstdCompress requires Node ≥ 22.15 / 23.8. On older runtimes, shell out to the zstd binary or use a WASM binding — but keep zstd in the pipeline, because it is the codec that makes browser-side decode cheap.

Tier 2 — Dynamic compression at origin (Brotli q5 / zstd -3)

For HTML and JSON rendered per request, use Brotli q5 as the primary and zstd -3 for large JSON payloads above ~64 KB. Never q11. A 25 KB HTML document at q5 costs 0.95 ms — invisible. The same document at q11 costs 23 ms, which is a measurable TTFB regression on every uncached request.

nginx
# --- Baseline: gzip. Always keep it; it is the universal fallback. ---
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css application/javascript application/json
           application/manifest+json image/svg+xml application/wasm;

# --- Brotli via ngx_brotli ---
brotli on;
brotli_comp_level 5;            # NEVER 11 in the request path
brotli_min_length 1024;
brotli_static on;               # prefer the precompressed .br from Tier 1
brotli_types text/plain text/css application/javascript application/json
             application/manifest+json image/svg+xml application/wasm;

# --- Zstandard via ngx_zstd ---
zstd on;
zstd_comp_level 3;
zstd_min_length 1024;
zstd_static on;
zstd_types text/plain text/css application/javascript application/json
           application/manifest+json image/svg+xml application/wasm;

gzip_vary on emits Vary: Accept-Encoding. Verify your Brotli and Zstd modules do the same — a missing Vary on a shared cache is how teams accidentally serve Brotli bytes to a gzip-only client and spend a weekend debugging mojibake.

Tier 3 — Edge negotiation with correct q-value handling

Content negotiation is where most implementations are subtly wrong. Accept-Encoding: gzip;q=0, br means Brotli only — gzip is explicitly forbidden. Parsing this correctly is non-optional.

ts
type ContentEncoding = 'br' | 'zstd' | 'gzip' | 'identity';

// Server preference order, highest priority first.
const PREFERENCE: readonly ContentEncoding[] = ['br', 'zstd', 'gzip'];

export function negotiateEncoding(header: string | null): ContentEncoding {
  if (!header) return 'identity';

  const quality = new Map<string, number>();
  for (const part of header.split(',')) {
    const [rawName, ...params] = part.trim().toLowerCase().split(';');
    if (!rawName) continue;

    let q = 1;
    for (const param of params) {
      const [key, value] = param.trim().split('=');
      if (key !== 'q') continue;
      const parsed = Number.parseFloat(value ?? '');
      // Malformed q-values fall back to 1 per RFC 9110 §12.4.2.
      q = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 0), 1) : 1;
    }
    quality.set(rawName, q);
  }

  // '*' acts as a floor for any encoding not explicitly listed.
  let best: ContentEncoding = 'identity';
  let bestQ = quality.get('*') ?? 0;

  for (const encoding of PREFERENCE) {
    const q = quality.get(encoding);
    if (q !== undefined && q > bestQ) {
      best = encoding;
      bestQ = q;
    }
  }

  return bestQ === 0 ? 'identity' : best;
}

Operational Checklist Before You Ship

  • Inventory your cache-key variants. Vary: Accept-Encoding fragments your CDN cache 3–4 ways. On a 95% hit-ratio workload this is free; on a 60% hit-ratio workload it can double origin load. Measure the hit ratio per encoding variant, not in aggregate.
  • Confirm browser support before defaulting to `Content-Encoding: zstd`. Chrome ≥ 123 and Firefox ≥ 126 accept it; Safari support lags. Negotiate — never assume.
  • Exclude incompressible media at the build step. The size >= original * 0.98 guard above is not optional; it is the difference between a clean manifest and 40 TB of pointless .br files.
  • *Emit `ETag` on the uncompressed representation* (or a weak validator) so 304 revalidation does not break across encoding variants.
  • Instrument encode cost as a first-class metric. Track p50/p99 compression time per request, split by content type. It will surprise you on large JSON.
  • Re-benchmark on your own corpus. Ratios move 10–20% between asset mixes; the break-even formula above is the transferable part.

Pitfalls That Quietly Cost Six Figures

Compressing at the edge on a miss. Edge CPUs are the most expensive vCPU you will ever buy. A cold-cache Brotli q11 on a 2 MB bundle blocks an edge node for nearly 2 seconds. Push compression to the build, and let the edge do nothing but negotiate and serve. Double compression. A reverse proxy gzipping an already-Brotli-compressed upstream response produces a larger, undecodable payload. Pin Content-Encoding at exactly one hop. Using gzip -9 "for extra savings." 2.3× the CPU for 0.1 points of ratio. Set gzip_comp_level 6 and walk away. Ignoring decode cost on mobile. Brotli's decoder is ~4× slower than zstd's. On a mid-range Android device, decompressing 3 MB of Brotli consumes measurable main-thread time. For large payloads destined for mobile, zstd is often the better user-facing choice even when Brotli wins on bytes. Forgetting that negotiation is per-request, not per-object. The same URL legitimately has four representations. Any cache or origin shield that keys only on the URL and not on the negotiated encoding will serve the wrong bytes eventually.

Sources & Technical References

1 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles