Compression Codec Trade-offs

Protocols & Interoperability intermediate Building with AI ~35 min read

Overview

Compression strategy is a systems design problem, not a library checkbox. Snappy, LZ4, Zstd, Brotli, and gzip all reduce bytes, but they optimize different parts of the design space: latency, ratio, decompression speed, dictionary support, ecosystem compatibility, streaming APIs, and operational predictability. A storage engineer choosing one codec for SSTables, one for network frames, and one for archives is really allocating CPU, memory bandwidth, disk, and compatibility risk.

Zstd was created to widen the historical speed-ratio frontier. Gregory Szorc’s analysis highlights the practical reasons this matters: benchmark results vary by corpus, but Zstd gives engineers a broad set of levels, a strong C API, and dictionary compression that can materially improve small-record workloads. In one Firefox omni.ja experiment, a trained 128 KiB dictionary reduced discrete-file compressed size by more than 1.1 MB compared with non-dictionary Zstd, and dictionary decompression over Mercurial changeset data improved from roughly 240 MB/s to roughly 530 MB/s.

The other codecs still matter. Snappy and LZ4 are often better for hot-path database blocks because they keep latency and CPU variance low. gzip remains the compatibility baseline for HTTP, archives, and legacy tooling. Brotli targets high-density web delivery and static assets, with an RFC-defined format built from LZ77, Huffman coding, and second-order context modeling. There is no universal winner; the right answer depends on where the bytes sit in the system and who pays the CPU bill.

Core Concepts

Latency Tier vs Ratio Tier

Snappy and LZ4 are latency-tier codecs. They are appropriate when compression and decompression happen on the request path or replication path. Zstd spans both tiers: low levels behave like fast storage codecs, while high levels compete with archive codecs. gzip and Brotli are ratio-tier or compatibility-tier choices: gzip because every platform understands it, Brotli because web delivery and static bundles often value transfer size more than compression CPU.

Compression vs Decompression Asymmetry

Many workloads write once and read many times. A backup job or bottommost LSM compaction can afford slower compression if every future read becomes smaller and decompression remains fast. A pub/sub producer, by contrast, pays compression cost for every batch under live traffic. A codec decision should therefore weight compression and decompression separately instead of treating “speed” as one scalar.

Dictionaries and Small Inputs

Small records have little local history, so ordinary stream compression often underperforms. Dictionary compression seeds the compressor and decompressor with representative patterns. Zstd, Brotli, and zlib support dictionary-style use cases, while Snappy and ordinary LZ4 are usually deployed without trained dictionaries. Dictionaries improve ratio only when training samples match production data and when producers and consumers can reliably distribute the same dictionary version.

Key Concept: Choose by Bottleneck

If the bottleneck is CPU, choose a faster codec or store hot data uncompressed. If the bottleneck is disk or network, choose a denser codec. If the bottleneck is compatibility, choose gzip or a format the ecosystem already supports. If the bottleneck is random access, use independent compressed blocks rather than a monolithic stream.

How It Works

A Workload-Weighted Score

A defensible codec choice starts with a workload model. Estimate how many bytes are compressed, how many are decompressed, how expensive storage or transfer is, and how much CPU time each codec consumes. The score does not need to be perfect; it needs to prevent ratio-only decisions that harm latency-sensitive paths.

type CodecBench struct {
    Name              string
    Ratio             float64 // uncompressed bytes / compressed bytes
    CompressMBPerSec  float64
    DecompressMBPerSec float64
}

type Workload struct {
    InputMiB          float64
    CompressesPerDay  float64
    DecompressesPerDay float64
    StorageCostPerMiBDay float64
    NetworkCostPerMiB    float64
    CpuCostPerSecond     float64
}

func estimateDailyCost(c CodecBench, w Workload) float64 {
    compressedMiB := w.InputMiB / c.Ratio
    savedMiB := w.InputMiB - compressedMiB

    compressCPU := (w.InputMiB / c.CompressMBPerSec) * w.CompressesPerDay
    decompressCPU := (w.InputMiB / c.DecompressMBPerSec) * w.DecompressesPerDay

    storage := compressedMiB * w.StorageCostPerMiBDay
    network := compressedMiB * w.NetworkCostPerMiB
    cpu := (compressCPU + decompressCPU) * w.CpuCostPerSecond

    // savedMiB is useful for reporting even if the objective is total cost.
    _ = savedMiB
    return storage + network + cpu
}

Decision Tree

For foreground database reads and writes, start with LZ4 or Snappy, then test low-level Zstd if ratio pressure is real. For cold LSM levels, backups, artifacts, and long-lived archives, start with Zstd and evaluate higher levels if compression runs offline. For HTTP text assets, compare Brotli and gzip because browser support and CDN behavior dominate. For legacy file exchange, gzip may be the only acceptable answer despite weaker density.

Block vs Stream APIs

Block compression is the default for indexed storage and random access. Stream compression is the default for logs, archives, HTTP responses, and bulk transfer. A stream codec can produce excellent ratio because it carries history across a long input, but random reads may require decompressing from the beginning or from a seekable checkpoint. A block codec gives up history to keep seeks cheap.

Practical Applications

LSM Storage Engines

An LSM tree can use multiple codecs at once. Memtable flushes and upper levels favor LZ4 or Snappy because they are rewritten frequently and read by foreground traffic. Bottommost levels favor Zstd because they contain most of the data and change less often. This is the RocksDB pattern: lightweight compression for normal levels, heavier bottommost compression when CPU is available.

HTTP and Static Asset Delivery

Web servers commonly serve gzip for broad compatibility and Brotli for clients that support it. Brotli’s higher levels are expensive enough that they are best for precompressed static assets, not dynamic per-request payloads. Zstd-over-HTTP is increasingly interesting, but deployment depends on client and proxy support; codec selection must include the whole delivery path, not only the origin server.

Telemetry, Events, and Small Records

Telemetry events, WAL records, and API payloads are often small and structurally similar. Zstd dictionaries can be a major win here, but only if dictionary lifecycle is engineered: training, versioning, rollout, fallback, monitoring, and retirement. Without that operational layer, dictionary compression can turn a data-saving feature into an incident source.

Implementation Patterns

Tiered Codec Policy

Represent compression choices as policy, not scattered constants. Each storage tier or transport should declare its latency goal, default codec, accepted fallbacks, minimum savings threshold, and maximum decompressed size. This keeps upgrades reviewable and prevents one team’s archive setting from leaking into another team’s hot path.

type CompressionPolicy struct {
    Tier              string
    DefaultCodec      string
    FallbackCodec     string
    MinSavingsPct     int
    MaxDecodedBytes   int
    AllowsDictionary  bool
}

var Policies = map[string]CompressionPolicy{
    "wal": {
        Tier: "foreground-log", DefaultCodec: "lz4", FallbackCodec: "raw",
        MinSavingsPct: 10, MaxDecodedBytes: 1 << 20, AllowsDictionary: false,
    },
    "sstable-upper": {
        Tier: "hot-storage", DefaultCodec: "lz4", FallbackCodec: "snappy",
        MinSavingsPct: 12, MaxDecodedBytes: 64 << 10, AllowsDictionary: false,
    },
    "sstable-bottom": {
        Tier: "cold-storage", DefaultCodec: "zstd", FallbackCodec: "lz4",
        MinSavingsPct: 5, MaxDecodedBytes: 256 << 10, AllowsDictionary: true,
    },
    "static-web": {
        Tier: "edge-delivery", DefaultCodec: "brotli", FallbackCodec: "gzip",
        MinSavingsPct: 1, MaxDecodedBytes: 16 << 20, AllowsDictionary: false,
    },
}

Dictionary Versioning

Dictionary-compressed frames need a dictionary id, a codec id, and a safe fallback behavior when the reader lacks the dictionary. A rolling deployment should publish the dictionary to all readers before writers start emitting frames that require it. Metrics should track dictionary hit rate, fallback rate, compression ratio, and decode failures by version.

Benchmark Harness Discipline

Benchmark with the same block sizes, data distributions, CPU limits, and concurrency that production uses. Include incompressible samples and adversarial small samples. Report compression speed, decompression speed, ratio, p50 and p99 latency, allocation rate, and total end-to-end time with I/O or network included. The Squash benchmark is useful because it exposes raw data and separates ratio, compression speed, decompression speed, and transfer-plus-processing views.

Performance Considerations

Common Pitfalls

Further Reading