Beyond gzip: Brotli compression for text payloads

Brotli is a newer compression algorithm that outperforms gzip on text-heavy assets. In practice, that translates to roughly 14% smaller JavaScript, 21% smaller HTML, and 17% smaller CSS files compared to gzip, according to benchmarks from CertSimple. Brotli is supported in all modern browsers, and a browser that supports it advertises that capability in its request headers.

This article walks through applying Brotli compression to a web app that already uses gzip. It assumes you understand the basics of compression. Using the shrink-ray module for dynamic compression and the brotli-webpack-plugin for static, build-time compression, we'll see how to shrink a JavaScript bundle that was previously reduced from 225 KB to 61.6 KB with gzip down to roughly 53 KB with Brotli.

Checking compression support

Before using Brotli, confirm your environment supports it. Browsers that support Brotli include br in the Accept-Encoding header of their requests:

Accept-Encoding: gzip, deflate, br

The Content-Encoding response header in the Chrome DevTools Network panel (opened with Command+Option+I or Ctrl+Alt+I) shows which compression algorithm the server actually used.

Your server must also support HTTPS to serve Brotli-compressed content.

Dynamic Brotli compression

Dynamic compression encodes assets on the fly when the browser requests them. It works well for pages that are generated or updated frequently because there are no pre-compressed files to maintain. The main trade-off is performance: higher compression levels take longer, so the user may wait while the server compresses an asset before sending it.

For a Node and Express server, the server.js file currently sets up the app by importing express and mounting the express.static middleware to serve the HTML, JS, and CSS files that webpack generates in the public/ directory.

To compress all requested assets with Brotli, use the shrink-ray module. Add it as a devDependency in package.json:

"devDependencies": {
  // ...
  "shrink-ray": "^0.1.3"
},

Then import it in server.js:

const express = require('express');
const shrinkRay = require('shrink-ray');

Mount it as middleware before express.static:

// ...
const app = express();

// Compress all requests
app.use(shrinkRay());
app.use(express.static('public'));

After reloading the app, the Network panel shows bz in the Content-Encoding header instead of gzip. The main bundle is now delivered at 53.1 KB instead of the 61.6 KB achieved with gzip—roughly 14% smaller.

Static Brotli compression with webpack

Static compression pre-encodes assets during the build. This eliminates per-request latency from high compression levels since the files are served directly. The cost is that compressing assets with high levels increases build times, and every build must re-run the compression step.

The brotli-webpack-plugin handles this as part of the webpack build. Add it as a devDependency in package.json:

"devDependencies": {
  // ...
 "brotli-webpack-plugin": "^1.1.0"
},

Then configure it in webpack.config.js. Import the plugin:

var path = require("path");

//...
var BrotliPlugin = require('brotli-webpack-plugin');

And include it in the plugins array:

module.exports = {
  // ...
  plugins: [
    // ...
    new BrotliPlugin({
      asset: '[file].br',
      test: /\.(js)$/
    })
  ]
},

This plugin configuration takes three arguments:

  • asset: The target asset name, where [file] is replaced with the original file name.
  • test: Only assets matching this RegExp—here, any file ending in .js—are processed.

For the example app, the plugin creates main.bundle.js.br alongside the original main.bundle.js in the public/ directory. To verify, open the Glitch Console by clicking Tools then Console, and run:

cd public
ls -lh

Bundle size with static Brotli compression

The pre-compressed file is ~76% smaller than the original 225 KB bundle. A route in server.js, defined before express.static is mounted, tells the server to serve these files:

const express = require('express');
const app = express();

app.get('*.js', (req, res, next) => {
  req.url = req.url + '.br';
  res.set('Content-Encoding', 'br');
  res.set('Content-Type', 'application/javascript; charset=UTF-8');
  next();
});

app.use(express.static('public'));

The route handles it as follows:

  • The '*.js' pattern matches every endpoint that requests a JS file.
  • The callback appends .br to the request URL and sets the Content-Encoding response header to br.
  • The Content-Type header is set to application/javascript; charset=UTF-8 to identify the MIME type.
  • next() passes control to any subsequent route handlers.

Because not every browser supports Brotli, verify that the request's Accept-Encoding header contains br before returning the compressed file:

const express = require('express');
const app = express();

app.get('*.js', (req, res, next) => {
  if (req.header('Accept-Encoding').includes('br')) {
    req.url = req.url + '.br';
    console.log(req.header('Accept-Encoding'));
    res.set('Content-Encoding', 'br');
    res.set('Content-Type', 'application/javascript; charset=UTF-8');
  }

  next();
});

app.use(express.static('public'));

After reloading the app, the Network panel shows the smaller Brotli-compressed payload being delivered.

The takeaway

Brotli compression reliably beats gzip for text content in browsers that support it. Both dynamic and static compression approaches with Node and Express are straightforward to set up, and static compression via a webpack plugin avoids any runtime compression costs. The result is a smaller overall application payload with no loss of fidelity.