---
title: Compression
description: Automatic brotli, zstd and gzip compression for text responses, negotiated per request, with static files compressed once.
---
`rust-web-server` compresses text responses automatically, in the coding the
client prefers: `br` (brotli), `zstd` or `gzip` (brotli and zstd since
18.1.0). No middleware registration, no configuration.
## Which coding
The request's `Accept-Encoding` decides: the coding with the highest `q`
wins, `*` stands for any coding not listed, and `q=0` refuses one. When
several are equally acceptable — a browser sends
`gzip, deflate, br, zstd` — the server's order breaks the tie:
| Static files (compressed once, then cached) | `br`, `zstd`, `gzip` | brotli's output is smallest, and it is paid for once |
| Everything else (compressed per response) | `zstd`, `br`, `gzip` | zstd costs the least CPU at a similar ratio |
A request without `Accept-Encoding` gets the response uncompressed.
Brotli and zstd are the `brotli` and `zstd` Cargo features, both on by
default (`zstd` builds the C libzstd). Without them only gzip is offered;
the `http1`-only build (`--no-default-features --features http1`) has
gzip only.
## What gets compressed
`compression::apply_compression(request, response)` runs after your
handler, before the response is written (HTTP/1.1, HTTP/2 and HTTP/3). It
compresses when:
1. The response has no `Content-Encoding` already — a backend's compressed
response is passed through, never compressed twice.
2. The body is at least 512 bytes (`compression::MIN_COMPRESS_SIZE`, the
same minimum Caddy uses).
3. The client accepts a coding (above).
4. The `Content-Type` is compressible (below).
5. The response isn't partial: a `206` is a slice of the file and is sent as
is (since 17.113.2).
Levels are the fast ones peers use for per-response compression: brotli 4,
zstd 3, gzip 6 (`compression::Level::Dynamic`; brotli's default, 11, is
about a hundred times slower than gzip). If the result isn't smaller than
the original, the original is sent. A compressed response gets:
- `Content-Encoding: br`, `zstd` or `gzip`
- `Vary: Accept-Encoding` (appended to an existing `Vary`, or added)
- its own `ETag` when it had one: `"x"` becomes `"x-br"` (since 18.1.0;
each representation needs a different strong ETag)
`compression::apply_gzip` still exists and only ever uses gzip.
## Compressible content types
Only responses whose `Content-Type` starts with one of these are compressed:
| `text/html` |
| `text/css` |
| `text/javascript` |
| `text/plain` |
| `text/xml` |
| `application/json` |
| `application/javascript` |
| `application/xml` |
| `application/xhtml+xml` |
| `image/svg+xml` |
Images, audio, video, fonts and archives are already compressed.
## Static files are compressed once
A static file is compressed once per version per coding, not on every
request: the open-file cache keeps the brotli, zstd and gzip forms of each
file up to 1 MiB, keyed by its size and modification time, within the same
8 MiB memory budget, at the higher `Level::Static` (brotli 6, zstd 6,
gzip 6). An edit is picked up at once; a file modified less than 2 seconds
ago isn't cached yet. The `ETag` carries the coding (`"<mtime>-<size>-br"`),
so a client revalidating gets a `304` for the representation it holds.
### Precompressed files
If `app.js.br`, `app.js.zst` or `app.js.gz` sits next to `app.js`, is a
regular file (not a symlink) and is at least as new as `app.js`, its bytes
are sent for that coding instead of compressing — as nginx's `gzip_static`
does. Build them once with the best settings (`brotli -q 11`,
`zstd -19`):
```bash
brotli -q 11 -k static/app.js # static/app.js.br
zstd -19 -k static/app.js # static/app.js.zst
gzip -9 -k static/app.js # static/app.js.gz
```
A sibling older than the file (the file was edited since) is ignored, and
the file is compressed instead. Like the cached forms, this applies to
files up to 1 MiB.
## Large files
Static files larger than **256 KiB** are sent without being read into
memory — `sendfile(2)` over plain HTTP, chunked otherwise — except that a
client accepting a coding gets a compressible file of up to **1 MiB**
compressed from the cache. Larger files are sent uncompressed.
:::note[Range requests]
Byte-range requests (`Range: bytes=N-M`) are answered with the slice,
uncompressed: compressing a slice would describe bytes that don't exist.
:::
## Dictionary compression
With a dictionary (RFC 9842), a new version of a file is sent as the
difference from a version the browser already has. The browser names the
dictionary it holds in `Available-Dictionary` (its SHA-256) and accepts
`dcz` (zstd) or `dcb` (brotli). Since 18.12.0.
### Static files
List the URL patterns of files whose next versions should go out as deltas
— typically bundles with a version or hash in the name:
```toml
compression_dictionaries = ["/js/app.*.js", "/css/site.*.css"] # RWS_CONFIG_COMPRESSION_DICTIONARIES
static_cache_control = "public, max-age=31536000, immutable" # browsers only use fresh dictionaries
```
A file matching a pattern (`*` matches any characters, `/` included) is sent
with `Use-As-Dictionary: match="<pattern>"` and `Vary: Accept-Encoding,
Available-Dictionary`, and rws keeps a copy of its bytes. After a deploy,
the browser asks for `/js/app.v2.js` naming `app.v1.js` as its dictionary,
and rws sends v2 as a delta against the copy it kept, `dcz` first. In the
end-to-end test a 102 KiB bundle with a few changed lines arrives in 85
bytes (`dcz`) or 73 (`dcb`), against 13.7 KiB as `br`, and Chromium
decodes it to the exact file.
- Each version's hash is worked out once; deltas are cached per version and
dictionary like the `br` forms, with an ETag of `"…-dcz-<hash prefix>"`.
- Files up to 1 MiB, compressible types only, `GET`/`HEAD` without `Range`.
- Kept dictionaries take up to 32 MiB, oldest out first, and are lost on
restart: until browsers fetch a file again they get `br` or `zstd`.
- A dictionary the server doesn't have (another server, an evicted one)
means the usual `br` / `zstd` / `gzip`.
- `static_cache_control` matters: browsers use a dictionary only while
it's fresh in their cache, and the default `no-cache` makes it stale at
once. Use versioned names with a long `max-age`.
### In code
`compression::dictionary` has the codec:
```rust
use rust_web_server::compression::{dictionary, Level};
use rust_web_server::request::Request;
/// `new` as a delta against `old`, when the client holds `old` and accepts
/// a dictionary coding. Send it with `dictionary::mark_encoded`.
fn delta(request: &Request, old: &[u8], new: &[u8]) -> Option<(dictionary::DictionaryEncoding, Vec<u8>)> {
let coding = dictionary::negotiate(request)?;
if dictionary::available_dictionary(request)? != dictionary::hash(old) {
return None;
}
Some((coding, dictionary::encode(coding, new, old, Level::Static)?)) // magic + hash + stream
}
```
`negotiate` picks `dcz` over `dcb` on equal weight and only when the
client lists the coding by name (`*` doesn't count). Dictionaries and bodies
are limited to 1 MiB each (`dictionary::MAX_SIZE`), which keeps the window
inside the 8 MiB browsers support. `decode` reverses `encode`, checking the
magic and the dictionary's hash, with a cap on the decoded size.
## Checking it
```bash
curl -s -D - -o /dev/null -H "Accept-Encoding: br" http://localhost:7878/app.js
# content-encoding: br
# vary: Accept-Encoding
# etag: "1793007086216830266-26624-br"
```
## Configuration
Compression needs none: there are no keys, environment variables or
middleware layers for `gzip`, `br` and `zstd`. Only dictionary compression
is opt-in, with `compression_dictionaries` (above).