HTTP compression spends CPU to save bytes
Follow HTTP content negotiation, then measure where gzip, Brotli and zstd trade encoding time for fewer response bytes.
Compressing the 502,941-byte plaintext of HTTP’s own specification with gzip level 6 produced 139,862 bytes. Raising gzip to level 9 saved only another 960 bytes. On the same input, the median encoding time rose from 20.8 ms to 42.2 ms.
The obvious policy is to select the smallest output. That ignores whether the server compresses once before publishing or again while each request waits. On this input, zstd 3 matches gzip 6’s size about nine times faster: 2.3 ms versus 20.8 ms.
We’ll follow content negotiation, then measure where smaller responses justify encoding time. In a simple buffered model, request-time gzip 6 costs more time than it saves above about 140 Mbit/s. These are in-memory encoding measurements on RFC text, not browser load times, streaming tests or a representative sample of web traffic.
Negotiate the coding, not the media type
Simply gzipping every response breaks the agreement with the receiver. RFC 9110’s Accept-Encoding rules let a client announce acceptable content codings, reversible transformations applied to response bytes. A quality value of zero rejects a coding. An empty field asks for no content coding.
The response’s Content-Encoding identifies the transformation actually applied. Content-Type still describes the decoded document, while Content-Length counts the compressed bytes. RFC 9110 Section 8.4 defines how content codings affect this metadata.
Compare the lengths in these illustrative response headers for the measured RFC file. Both responses decode to the same document.
HTTP/1.1 200 OKContent-Type: text/plainContent-Length: 502941Vary: Accept-EncodingHTTP/1.1 200 OKContent-Type: text/plainContent-Encoding: gzipContent-Length: 139862Vary: Accept-EncodingThe gzip response has a smaller Content-Length with the same Content-Type. Both variants carry Vary because the request’s encoding preferences influenced selection. RFC 9110 Section 12.5.5 explains how Vary expands the cache key.
Use Next to reach the second message below and check which coding was actually selected. The initial step advertises gzip and Brotli as equally acceptable. The origin chooses an available gzip variant.
Step 1 of 4: Client to Origin, Accept-Encoding: gzip, br. Both listed codings have the default quality of 1. The origin has a gzip variant available.
The second request rejects gzip, so the origin sends the uncompressed response. The figure shows two independent requests and omits caches.
Gzip wraps DEFLATE with a 10-byte fixed header, optional header fields, and an 8-byte trailer holding CRC-32 and size modulo 2³². The HTTP deflate label means DEFLATE inside a zlib wrapper, not raw DEFLATE.
Higher levels buy diminishing size reductions
Brotli’s format combines LZ77 and Huffman coding, with context modeling and a static dictionary. Zstandard defines its own frame format and registers the zstd content coding. We’ll compare each codec separately on the same input.
The experiment uses the RFC Editor’s exact rfc9110.txt, downloaded on 2026-11-03 without text normalization. Check its SHA-256 before comparing measurements:
21c1cdce6ab0e5509b04d84a28000836c7a087cf786efe6f04877ebfff47232aThe checksum identifies the exact input, including its line endings. The table selects five profiles from the 22 measured settings and includes an uncompressed baseline. Measurements use Python 3.12.3, zlib 1.3, Brotli 1.1.0 and libzstd 1.5.5, with medians from seven runs.
| Representation | Bytes | Saved (%) | Median encode (ms) |
|---|---|---|---|
| Uncompressed(baseline) | 502941 | 0.00 | n/a |
| Gzip 1 | 173551 | 65.49 | 5.547 |
| Gzip 6 | 139862 | 72.19 | 20.750 |
| Brotli 5 | 129713 | 74.21 | 15.775 |
| Brotli 11 | 108502 | 78.43 | 897.355 |
| Zstd 3 | 139228 | 72.32 | 2.309 |
Gzip 6 removes 502,941 - 139,862 = 363,079 bytes, or 72.19%. Brotli 11 removes another 139,862 - 108,502 = 31,360 bytes, but its median encoding time is about 43 times gzip 6’s. The uncompressed baseline has no encoding time because it does not call a compressor.
Within gzip, follow the curve from level 6 to level 9 and notice how little the output size changes.
Gzip's size curve flattens after level 6.
Hover, tap or focus a mark to read its value. With JavaScript, use arrow keys to move between values and Enter or Space to show a value.
The first five level increases save 173,551 - 139,862 = 33,689 bytes. The final three save 960 bytes, less than 0.2% of the original file.
Now compare all 22 settings by measured time rather than their level numbers. A point farther left costs less encoding time, and a point lower down leaves fewer bytes to transmit.
Similar output sizes can have very different encoding costs.
Hover, tap or focus a mark to read its value. With JavaScript, use arrow keys to move between values and Enter or Space to show a value.
Zstd 3 produced 139,228 bytes in a median 2.3 ms, compared with gzip 6’s 139,862 bytes in 20.8 ms. Brotli 11 produced the fewest bytes but took the longest to encode. Compare size and time together. Codec level numbers are not a common scale across implementations.
The same level saves different byte counts
The break-even rate derived in the next section depends on bytes saved per millisecond of encoding, so response size matters alongside the compression level. To see how sizes vary within RFC text, compare these 20 files compressed separately at gzip level 6. Each histogram entry counts one file.
Fifteen of these twenty gzip outputs fit below 60 kB.
Hover, tap or focus a mark to read its value. With JavaScript, use arrow keys to move between values and Enter or Space to show a value.
RFC 768 shrinks from 5,896 to 1,924 bytes, while RFC 9110 shrinks from 502,941 to 139,862 bytes. The same level gives very different absolute savings across response sizes. A request-time policy needs the encoding cost beside those savings to decide whether compression pays off.
Pay once when bytes can be reused
Model a buffered response by encoding it completely, then transmitting at bytes per second, ignoring client decoding and costs shared by both variants.
Here is the uncompressed size and is the compressed size. At 10,000,000 bytes/s, gzip 6’s 363,079 saved bytes avoid about 36.3 ms of transfer time. Subtracting its measured 20.8 ms encoding cost leaves about 15.6 ms.
For gzip 6, (502,941 - 139,862) / 0.020750 gives about 17.50 million bytes/s, or 140 Mbit/s. Above that rate, saved transfer time is smaller than encoding time. This threshold comes from the buffered model. Overlapping compression with transmission changes the model.
For an unchanged static asset, precompression moves encoding off the request path. The asset still needs negotiation and a correctly labeled body. When a runtime response already has valid gzip bytes, reuse them:
const coded = precompressedGzip;This avoids request-time encoding while precompressedGzip matches the current source and selected coding.
For reusable public assets, measure smaller responses at build time and serve only codings the client accepts. For request-time encoding, measure the actual response mix and keep encoding time beside output size. The opening 960-byte improvement is real, but paying another 21 ms for it is a separate decision.
Reproduce all measurements and inspect the input manifest
These measurements used Python 3.12.3, zlib 1.3, Brotli 1.1.0 and libzstd 1.5.5 on Linux 6.8.0-147-generic. The machine was an x86-64 KVM guest reporting an AMD EPYC Processor and six vCPUs. Calls ran serially without CPU affinity, with one warm-up and seven timed runs per setting in fixed order. Scheduling noise remains.
Input download and reading happened before timing. The timed region includes wrapper calls, output allocation and copying, but excludes decompression. All 154 timed outputs across the 22 settings were decoded and checked byte-for-byte against the original input. Gzip used mtime=0, Brotli used generic mode with lgwin=22, and zstd used ZSTD_compress without a trained dictionary.
Save the following as compression-bench.py and run it with Python 3.12.3 on a system with the Brotli encoder/decoder and zstd shared libraries. The measured Ubuntu packages were libbrotli1 1.1.0-2ubuntu0.1, libzstd1 1.5.5+dfsg2-2build1.1 and zlib1g 1:1.3.dfsg-3.1ubuntu2.3. The script saves each input, prints its size and SHA-256, and records all seven raw timings alongside the medians.
import ctypes as cimport ctypes.utilimport gzipimport hashlibimport jsonimport pathlibimport platformimport statisticsimport timeimport urllib.requestimport zlib
root = pathlib.Path("http-compression-data")root.mkdir(exist_ok=True)numbers = [768, 791, 1950, 1951, 1952, 3986, 6298, 7541, 7932, 8200, 8259, 8446, 8878, 9000, 9110, 9111, 9112, 9113, 9114, 9293]files = {}for number in numbers: path = root / f"rfc{number}.txt" if not path.exists(): path.write_bytes(urllib.request.urlopen( f"https://www.rfc-editor.org/rfc/rfc{number}.txt").read()) files[path.name] = path.read_bytes()
enc = c.CDLL(ctypes.util.find_library("brotlienc"))dec = c.CDLL(ctypes.util.find_library("brotlidec"))zs = c.CDLL(ctypes.util.find_library("zstd"))enc.BrotliEncoderVersion.restype = c.c_uint32enc.BrotliEncoderMaxCompressedSize.argtypes = [c.c_size_t]enc.BrotliEncoderMaxCompressedSize.restype = c.c_size_tenc.BrotliEncoderCompress.argtypes = [c.c_int, c.c_int, c.c_int, c.c_size_t, c.c_void_p, c.POINTER(c.c_size_t), c.c_void_p]dec.BrotliDecoderDecompress.argtypes = [c.c_size_t, c.c_void_p, c.POINTER(c.c_size_t), c.c_void_p]zs.ZSTD_versionString.restype = c.c_char_pzs.ZSTD_compressBound.argtypes = [c.c_size_t]zs.ZSTD_compressBound.restype = c.c_size_tzs.ZSTD_compress.argtypes = [c.c_void_p, c.c_size_t, c.c_void_p, c.c_size_t, c.c_int]zs.ZSTD_compress.restype = c.c_size_tzs.ZSTD_isError.argtypes = [c.c_size_t]zs.ZSTD_decompress.argtypes = [c.c_void_p, c.c_size_t, c.c_void_p, c.c_size_t]zs.ZSTD_decompress.restype = c.c_size_t
def compress(data, codec, level): if codec == "gzip": return gzip.compress(data, compresslevel=level, mtime=0) if codec == "brotli": capacity = enc.BrotliEncoderMaxCompressedSize(len(data)) output = c.create_string_buffer(capacity) size = c.c_size_t(capacity) if enc.BrotliEncoderCompress(level, 22, 0, len(data), data, c.byref(size), output) != 1: raise RuntimeError("Brotli compression failed") return output.raw[:size.value] output = c.create_string_buffer(zs.ZSTD_compressBound(len(data))) size = zs.ZSTD_compress(output, len(output), data, len(data), level) if zs.ZSTD_isError(size): raise RuntimeError("Zstandard compression failed") return output.raw[:size]
def verify(encoded, original, codec): if codec == "gzip": assert gzip.decompress(encoded) == original return output = c.create_string_buffer(len(original)) if codec == "brotli": size = c.c_size_t(len(original)) assert dec.BrotliDecoderDecompress(len(encoded), encoded, c.byref(size), output) == 1 assert size.value == len(original) else: assert zs.ZSTD_decompress(output, len(output), encoded, len(encoded)) == len(original) assert output.raw == original
data = files["rfc9110.txt"]rows = []for codec, levels in [("gzip", range(1, 10)), ("brotli", [1, 3, 5, 7, 9, 11]), ("zstd", [1, 3, 5, 7, 9, 15, 19])]: for level in levels: compress(data, codec, level) times = [] for run in range(7): start = time.perf_counter_ns() encoded = compress(data, codec, level) times.append((time.perf_counter_ns() - start) / 1e6) verify(encoded, data, codec) rows.append(dict(codec=codec, level=level, bytes=len(encoded), ms=round(statistics.median(times), 3), runs=times))
corpus = [dict(file=name, raw=len(value), gzip=len(compress(value, "gzip", 6)), sha256=hashlib.sha256(value).hexdigest()) for name, value in files.items()]result = dict(python=platform.python_version(), kernel=platform.release(), zlib=zlib.ZLIB_RUNTIME_VERSION, brotli=hex(enc.BrotliEncoderVersion()), zstd=zs.ZSTD_versionString().decode(), input=len(data), rows=rows, corpus=corpus)print(json.dumps(result, indent=2))(root / "results.json").write_text(json.dumps(result, indent=2) + "\n")The gzip call uses Python’s documented reproducible timestamp option. All codecs receive the entire input in one call. The measured encoding cost includes allocation strategies and wrapper overhead.
Run the benchmark once per environment and keep its manifest with the results. The command prints JSON rather than mixing download progress with timings.
python3 compression-bench.py > compression-results.jsonCompare the emitted input hash before comparing output sizes. Timings will vary with the machine. The charts show the recorded run above.