Serving Precompressed Brotli And Gzip From Nginx

2 min readThis Blog, Web

Jake Marsh

Every text file on Deallocated Objects is compressed before it reaches the server. The generator writes index.html, then writes index.html.br and index.html.gz right next to it, and nginx picks whichever one the browser asked for. The homepage goes over the wire at about 14 KB instead of about 61 KB, and nginx does no compression work to get there.

The Build Step#

The last thing src/build.ts does is walk the output folder and compress the text files. Bun ships node:zlib with Brotli built in, so there’s no extra package:

src/build.tsTypeScript
const { brotliCompressSync, gzipSync, constants: Z } = await import("node:zlib");

// ---------- precompress (nginx gzip_static + brotli_static serve these) ----------
let compressed = 0;
(function walk(d: string) {
  for (const f of rd(d)) {
    const full = join(d, f);
    if (st(full).isDirectory()) { walk(full); continue; }
    if (!/\.(html|css|js|json|xml|atom|txt|md|svg|xsl|webmanifest)$/.test(f) || st(full).size < 1024) continue;
    const buf = rf(full);
    wf(`${full}.br`, brotliCompressSync(buf, { params: { [Z.BROTLI_PARAM_QUALITY]: 11, [Z.BROTLI_PARAM_SIZE_HINT]: buf.length } }));
    wf(`${full}.gz`, gzipSync(buf, { level: 9 }));
    compressed++;
  }
})(dist);

Images and fonts stay off the extension list because they’re already compressed, and files under 1 KB aren’t worth the trouble. The build deletes dist/ before it writes anything and the deploy uses rsync --delete, so a .br can’t go stale next to a newer page.

Why Quality 11 Belongs At Build Time#

Here’s the JSON Feed (443,921 bytes) at a few settings, averaged over 10 runs. My Mac was busy when I timed these, so look at the ratios more than the milliseconds.

Setting Size Time
gzip level 9 81,072 21 ms
Brotli quality 5 75,120 19 ms
Brotli quality 9 72,616 36 ms
Brotli quality 11 65,517 1,746 ms

Quality 11 takes about 50 times the CPU of quality 9 to shave off another 10%. Nobody should pay that per request, but paying it once on my Mac at build time means every reader gets the smaller file.

The Nginx Side#

Ubuntu 24.04 packages Brotli as a dynamic module for its stock nginx, so apt install libnginx-mod-http-brotli-static and a reload is the whole setup. Then the vhost turns on both:

deploy/nginx.confnginx
gzip_static on;
brotli_static on;
gzip_vary on;

If the request’s Accept-Encoding includes br and a .br file exists, nginx sends that. Otherwise it tries the .gz, and then the plain file. This works through try_files too, so /posts/<slug> resolves to its .html and picks up the compressed files next to it. Because these are real files on disk, responses get a normal Content-Length, and gzip_vary adds the Vary: Accept-Encoding that keeps shared caches from handing Brotli bytes to a client that can’t read them.

I keep the .gz files because browsers aren’t the only readers. Feed readers, crawlers and scripts often ask only for gzip.

One Gotcha With Proxies#

While testing I noticed this:

Shell
$ curl -sI -H 'Accept-Encoding: gzip' -H 'Via: 1.1 proxy' https://deallocatedobjects.com/ | grep -i content-length
content-length: 60621
$ curl -sI -H 'Accept-Encoding: br' -H 'Via: 1.1 proxy' https://deallocatedobjects.com/ | grep -i content-length
content-length: 13945

gzip_static follows nginx’s gzip_proxied setting, which defaults to off, so a request carrying a Via header gets the uncompressed file. The Brotli module doesn’t check it. Nothing sits in front of this site, but if you put a CDN or proxy in front of nginx that adds Via, set gzip_proxied any or your gzip clients will quietly get full-size files.