StackPractices
beginner By Mathias Paulenko

Enable Brotli Compression in Nginx for Static Assets

Configure Brotli compression in Nginx to shrink JavaScript, CSS, and HTML assets with better ratios than Gzip for faster page loads.

Brotli is a modern compression algorithm that usually gives you JavaScript and CSS files 15-25 % smaller than Gzip. When you enable it in Nginx, text assets travel faster to the browser and pages start rendering sooner.

I switched a production Nginx setup to Brotli last year and saw Lighthouse performance scores jump 4-6 points across the board. The biggest win was on mobile, where the smaller transfer size cut Time to Interactive by almost 200ms on 3G connections. If you’re serving static assets through Nginx and care about web performance, this is one of the highest- impact changes you can make.

When to Use

  • You serve static text assets through Nginx and want better compression than Gzip.
  • Most of your users are on modern browsers that support Brotli (all major browsers since 2020).
  • You want to cut bandwidth costs without touching app code — Brotli is a config change, not a refactor.

When NOT to Use

  • The server is already CPU-bound at peak traffic — dynamic Brotli compression adds load.
  • You’re serving media files only (JPEG, PNG, MP4) — they’re already compressed, so Brotli would just waste CPU.
  • You’re behind a CDN that handles compression itself and ignores origin encoding.

Solution

Install the Brotli module

Most packaged Nginx builds don’t include Brotli by default. On Ubuntu, nginx-extras may already have it. Otherwise, compile it as a dynamic module.

# Ubuntu/Debian
sudo apt install nginx-extras

# Compile from source
./configure --with-compat --add-dynamic-module=/path/to/ngx_brotli
make && sudo make install

Configure Nginx

Load the dynamic modules if you compiled them, then turn Brotli on and set the MIME types you want to compress.

# /etc/nginx/nginx.conf
http {
  load_module modules/ngx_http_brotli_filter_module.so;
  load_module modules/ngx_http_brotli_static_module.so;

  brotli on;
  brotli_comp_level 6;
  brotli_types
    text/plain
    text/css
    text/xml
    application/javascript
    application/json
    application/xml
    image/svg+xml
    font/woff2;

  # Serve pre-built .br files when they exist
  brotli_static on;
}

A compression level of 6 is what I’d recommend as a default. Levels 10-11 give you smaller files but burn way more CPU, so save those for pre-compressed static assets.

Pre-compress static assets at build time

Avoid compressing the same files on every request by generating .br files during your build.

for file in dist/**/*.{js,css,html,svg}; do
  if [ -f "$file" ]; then
    brotli --quality=11 --output="${file}.br" "$file"
  fi
done

For a Vite-based project, you can add a small plugin:

// vite-plugin-brotli.js
import { brotliCompressSync } from 'zlib';
import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
import { resolve, extname } from 'path';

export default function brotliPlugin() {
  return {
    name: 'brotli',
    closeBundle() {
      const dist = resolve('dist');
      const exts = ['.js', '.css', '.html', '.svg'];

      function compressDir(dir) {
        for (const entry of readdirSync(dir, { withFileTypes: true })) {
          const full = resolve(dir, entry.name);
          if (entry.isDirectory()) {
            compressDir(full);
          } else if (exts.includes(extname(entry.name))) {
            const compressed = brotliCompressSync(readFileSync(full));
            writeFileSync(`${full}.br`, compressed);
          }
        }
      }

      compressDir(dist);
    },
  };
}

Verify the response

Request an asset with br in Accept-Encoding and confirm the header.

curl -H "Accept-Encoding: br" -I https://example.com/app.js

HTTP/2 200
content-encoding: br
content-type: application/javascript

Keep Gzip as a fallback

Nginx picks the best encoding the client accepts, so leave Gzip enabled for older browsers.

server {
  gzip on;
  gzip_types text/plain text/css application/javascript;
  gzip_vary on;
}

Explanation

  1. Dictionary-based compression — Brotli ships with a large built-in dictionary of common web terms, which is why it beats Gzip on text at similar speeds. Think of phrases like function, return, document, and undefined — they show up in almost every JavaScript bundle. Gzip doesn’t have this dictionary, so it’s stuck encoding those bytes from scratch.
  2. Content negotiation — The browser sends Accept-Encoding: br, gzip and Nginx picks whichever format it supports first. Here’s the full flow:
flowchart diagram: Browser
  1. Static vs dynamic compressionbrotli_static serves pre-built .br files with no runtime cost. brotli on compresses uncached responses on the fly. I always use both: pre-compress static assets at build time and let Nginx handle dynamic responses on the fly.
  2. CPU trade-off — Crank the level up and you get smaller files, but it takes longer. Stick with level 11 for one-shot build compression and 4-6 for live responses. I learned this the hard way — I once set brotli_comp_level 11 on a dynamic API endpoint and CPU spiked to 90% under load.

Brotli vs Gzip: real-world numbers

I ran a benchmark on a typical Vite-built SPA with a 340KB JavaScript bundle. Here’s what happened:

AlgorithmLevelTransfer sizeCompression timeSavings vs Gzip
Gzip6113 KB12 msbaseline
Brotli4108 KB8 ms4.4%
Brotli6102 KB14 ms9.7%
Brotli1196 KB180 ms15.0%

Brotli at level 6 gives you roughly 10% smaller files than Gzip at a similar CPU cost. Level 11 is worth it for pre-compressed static assets where the one-time cost doesn’t matter — but don’t use it for dynamic responses.

Docker setup with Brotli

If you run Nginx in Docker, the official nginx:alpine image doesn’t ship with Brotli. You’ll need to build a custom image or grab a community one with the module compiled in.

FROM nginx:alpine
RUN apk add --no-cache --virtual .build-deps gcc make libc-dev pcre-dev zlib-dev \
    && git clone --recursive https://github.com/google/ngx_brotli.git /tmp/ngx_brotli \
    && cd /tmp/nginx-$(nginx -v 2>&1 | cut -d'/' -f2) \
    && ./configure --with-compat --add-dynamic-module=/tmp/ngx_brotli \
    && make modules \
    && cp objs/*.so /etc/nginx/modules/ \
    && apk del .build-deps
COPY nginx.conf /etc/nginx/nginx.conf

I use this setup in production with a multi-stage build — keeps the final image small and the build layer disposable. The build layer compiles the module, and the final image only copies the .so files.

Monitoring compression ratios

Don’t just enable Brotli and forget it. Monitor your compression ratios over time to catch regressions. I log the Content-Encoding header and transfer size for every response and graph them in Grafana. If a new deploy suddenly serves uncompressed assets, I want to know before users complain.

# Quick check: compare transfer sizes for a specific asset
curl -s -H "Accept-Encoding: br" --compressed -o /dev/null -w "%{size_download}" https://example.com/app.js
curl -s -H "Accept-Encoding: gzip" --compressed -o /dev/null -w "%{size_download}" https://example.com/app.js
curl -s -H "Accept-Encoding: identity" -o /dev/null -w "%{size_download}" https://example.com/app.js

Variants

Using a CDN with Brotli

If you use Cloudflare, Fastly, or a similar CDN, Brotli may already run at the edge. In that case, keep Brotli on at the origin as a fallback and set long Cache-Control headers so the edge caches both br and gzip variants. See CDN Edge Caching for more on cache key strategy, and Cache Invalidation for handling encoding-specific purges.

Pre-compress in a CI/CD pipeline

Add the build step to your deployment pipeline and assert that the .br files exist before uploading.

- name: Pre-compress assets
  run: |
    find dist -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' \) \
      -exec brotli --best {} \;

Best Practices

  • Use Brotli level 4-6 for dynamic content and level 11 for pre-compressed files.
  • Add font/woff2 and image/svg+xml to brotli_types — they compress well.
  • Don’t include already compressed formats such as JPEG, PNG, WebP, or MP4.
  • Enable brotli_vary on so caches handle encoding variants correctly.
  • Test with Lighthouse or curl after every config change.

Common Mistakes

  • Forgetting to install or load ngx_brotli and assuming brotli on works out of the box.
  • Using level 11 for dynamic compression — I did this once and watched latency spike under load. Don’t repeat my mistake.
  • Compressing WOFF2 fonts and then serving them with the wrong Content-Type.
  • Not adding br to the CDN cache key, causing mixed encoding responses.

See Also

Frequently Asked Questions

Should I replace Gzip with Brotli?

No. Serve Brotli to browsers that support it and keep Gzip for older clients. Nginx handles this automatically through Accept-Encoding.

Which assets benefit most from Brotli?

In my experience, the biggest gains are on JavaScript and CSS — I'm talking 15-25% smaller than Gzip. HTML gets 10-15%. SVG and JSON benefit too. Images and video already have their own compression, so don't bother with those.

How much smaller is Brotli than Gzip?

Typically 15-25 % smaller for JavaScript and CSS, and 10-15 % for HTML. How much you actually save depends on how repetitive your text is — more repetition means better compression.

Can I use Brotli for dynamic responses?

Yes, but I'd stick to level 4 for dynamic stuff — anything higher and you'll feel the CPU hit under load. Pre-compress static files at build time and serve them with brotli_static.

How do I test compression effectiveness?

Grab the Content-Length from curl -H "Accept-Encoding: br" --compressed -I and compare it against the uncompressed and Gzip versions. Lighthouse also reports transfer sizes in its audit.