Precompress static assets
Published
Reviewedbyfl
🗜️
Compress at build time.
Compress CSS, JS and SVG files once during the build, at the highest level, and let Apache serve the finished files. Responses get smaller and the web server stops compressing the same file on every request.
About precompression
fortrabbit compresses text responses on the fly with GZIP at level 1. Brotli, when enabled in .htaccess, runs at quality 0. Those are the fastest settings and the weakest, and .htaccess cannot change them. They are set that way on purpose: Apache shares the CPU with PHP, and every request for a stylesheet compresses that stylesheet again.
Static assets do not change between deployments. Compress them once in the build and the level no longer costs anything per request. The same 253 kB stylesheet, four ways:
| Compression | Transferred |
|---|---|
| On the fly, GZIP | 45 kB |
| On the fly, Brotli | 44 kB |
| Precompressed, GZIP level 9 | 35 kB |
| Precompressed, Brotli quality 11 | 26 kB |
1. Compress during the build
Node.js ships GZIP and Brotli in its zlib module, so no extra package is needed. Place this script in the project root and point dir at the folder the asset build writes to:
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { brotliCompressSync, constants, gzipSync } from 'node:zlib'
const dir = 'public/build'
for (const name of readdirSync(dir, { recursive: true })) {
if (!/\.(css|js|svg)$/.test(name)) continue
const file = join(dir, name)
const raw = readFileSync(file)
writeFileSync(`${file}.gz`, gzipSync(raw, { level: 9 }))
writeFileSync(`${file}.br`, brotliCompressSync(raw, {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}))
}
Run it after the asset build in the build commands:
npm ci
npm run build
node compress.mjs
app.css now sits next to app.css.br and app.css.gz.
2. Serve the compressed files
Add this to the .htaccess in the root path. It rewrites a request to the .br or .gz sibling when the browser accepts that encoding and the file exists:
<IfModule mod_headers.c>
RewriteEngine On
# Brotli first
RewriteCond %{HTTP:Accept-Encoding} br
RewriteCond %{REQUEST_FILENAME}.br -f
RewriteRule ^(.+)\.(css|js|svg)$ $1.$2.br [L]
# GZIP for the rest
RewriteCond %{HTTP:Accept-Encoding} gzip
RewriteCond %{REQUEST_FILENAME}.gz -f
RewriteRule ^(.+)\.(css|js|svg)$ $1.$2.gz [L]
# Keep the content type, skip on-the-fly compression
RewriteRule \.css\.(br|gz)$ - [T=text/css,E=no-gzip:1,E=no-brotli:1]
RewriteRule \.js\.(br|gz)$ - [T=text/javascript,E=no-gzip:1,E=no-brotli:1]
RewriteRule \.svg\.(br|gz)$ - [T=image/svg+xml,E=no-gzip:1,E=no-brotli:1]
<FilesMatch "\.(css|js|svg)\.br$">
Header set Content-Encoding br
Header append Vary Accept-Encoding
</FilesMatch>
<FilesMatch "\.(css|js|svg)\.gz$">
Header set Content-Encoding gzip
Header append Vary Accept-Encoding
</FilesMatch>
</IfModule>
Place the block above the framework's front controller rules. A file without a compressed sibling is still compressed on the fly.
3. Check the response
Request an asset with each encoding and compare content-length with the size of the file on disk:
curl -sI -H 'Accept-Encoding: br' https://example.com/build/app.css
The response carries content-encoding: br, content-type: text/css and the byte count of app.css.br.
Limitations
- Only files that exist after the build are covered. HTML rendered by PHP is still compressed on the fly.
- Images, video and
woff2fonts are compressed formats. Do not add them to the pattern. - Assets changed on the server after the deployment keep their old
.brand.gzsiblings until the next build.