Static asset optimisation: compression and modern images
Brotli and gzip negotiation, minification and bundling decisions, WebP and AVIF with fallbacks, responsive image markup, and content hashing for long-lived caches.
Compression
# Brotli when the client offers it, gzip as the fallback
brotli on;
brotli_comp_level 5;
brotli_types text/plain text/css application/javascript application/json image/svg+xml;
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_vary on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
Never compress an already-compressed format. Re-compressing a JPEG or a WebP wastes CPU and can make it larger.
Vary: Accept-Encoding is mandatory, or a proxy will serve a Brotli response to a client that cannot decode it.
Level 5 Brotli costs a fraction of level 11 and gets most of the benefit; build-time compression for static assets can afford level 11.
Pre-compress static assets at build time and serve them directly. Compression per request is CPU spent on something that never changes.
# what is actually being served, and how large it really is
curl -sI -H "Accept-Encoding: br, gzip" https://example.com/static/app.js \
| grep -iE "content-encoding|content-length|vary|cache-control"
# measure the transfer size, not the decoded length
curl -s -o /dev/null -w "encoded bytes: %{size_download}\n" \
-H "Accept-Encoding: br" https://example.com/static/app.js
Serve at the size it is displayed. A 2000 px image shown in a 400 px box wastes bandwidth on every visit.
Always set width and height so the browser reserves space and the layout does not shift.
Lazy load everything below the fold, and never the image that is the largest contentful paint.
Optimise with a tool or a build step, not by re-exporting from a design tool at a guess.
Strip metadata. Camera and editor metadata is often larger than the visible difference from removing it.
Hashing, minifying and bundling
Content-hashed filenames and a long lifetime
/static/app.8f3c2a1b.js Cache-Control: public, max-age=31536000, immutable
/static/app.css.4d1e9f02.css same
/index.html Cache-Control: public, max-age=0, s-maxage=300
The rule: if the URL changes when the content changes, the URL can be cached
forever. If the URL is stable, the lifetime must be short - pick one, not both.
Decision
Prefer
Reason
Bundle everything or split?
Split by change frequency
A vendor bundle changes rarely and caches longer
Minify?
Yes, in the build
Smaller payload with no runtime cost
Tree shake?
Yes
Unused exports are the largest source of dead weight
Inline critical CSS?
Yes, under about 14 KB
Removes a render-blocking request
Defer non-critical JS?
Yes
Parsing JavaScript blocks interaction
Keep source maps?
Publicly reachable or omitted
Ship them privately for error tracking
⚠️
Changing the hash of a file that has not changed is as harmful as not hashing at all: it invalidates every user's cache on every deploy. Generate the hash from the content, never from the build timestamp or the commit id.
FAQ
Should compression happen at the CDN or the origin?
At the CDN when the response is dynamic, and at build time for static files. Compressing at the origin adds CPU to every request the edge has to forward.
How do I know which image format to choose?
Serve AVIF or WebP to clients that advertise it and keep a JPEG fallback. `` with a `` per format does this in markup with no server logic.