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
- 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, andundefined— they show up in almost every JavaScript bundle. Gzip doesn’t have this dictionary, so it’s stuck encoding those bytes from scratch. - Content negotiation — The browser sends
Accept-Encoding: br, gzipand Nginx picks whichever format it supports first. Here’s the full flow:
- Static vs dynamic compression —
brotli_staticserves pre-built.brfiles with no runtime cost.brotli oncompresses 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. - 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 11on 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:
| Algorithm | Level | Transfer size | Compression time | Savings vs Gzip |
|---|---|---|---|---|
| Gzip | 6 | 113 KB | 12 ms | baseline |
| Brotli | 4 | 108 KB | 8 ms | 4.4% |
| Brotli | 6 | 102 KB | 14 ms | 9.7% |
| Brotli | 11 | 96 KB | 180 ms | 15.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/woff2andimage/svg+xmltobrotli_types— they compress well. - Don’t include already compressed formats such as JPEG, PNG, WebP, or MP4.
- Enable
brotli_vary onso caches handle encoding variants correctly. - Test with Lighthouse or
curlafter every config change.
Common Mistakes
- Forgetting to install or load
ngx_brotliand assumingbrotli onworks 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
brto the CDN cache key, causing mixed encoding responses.
See Also
- Nginx Brotli module (ngx_brotli) — official GitHub repo
- Google Brotli documentation — compression algorithm reference
- Cloudflare: Brotli compression — real-world CDN results
- web.dev: Reduce JavaScript payloads — compression as part of bundle optimization
- Mozilla: Content-Encoding — HTTP header reference
- Compression with Gzip — Gzip setup and fallback strategy
- CDN Edge Caching — caching compressed variants at the edge
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.
Related Resources
Compress and Decompress Files with Gzip and Brotli
How to reduce file sizes for APIs, static assets, and log files using Gzip, Brotli, and zlib with streaming compression, content negotiation, and what works.
RecipeImplement CDN Edge Caching
Configure content delivery networks with edge caching rules, cache invalidation, and geographic optimization for static and live content.
RecipeWeb Performance Optimization
Improve Core Web Vitals, reduce bundle sizes, and optimize frontend performance with lazy loading, code splitting, and modern build tools.
RecipeImplement Cache Invalidation Strategies
How to keep caches consistent with databases using TTL, write-through, write-behind, and event-driven invalidation patterns.
RecipeImplement Lazy Loading for Images, Components, and Data
How to defer loading of non-critical resources until they are needed, improving initial page load time, reducing bandwidth, and optimizing Core Web Vitals.
GuideWeb Performance Optimization Guide
A thorough guide to optimizing web application performance for better Core Web Vitals and user experience.