Caching and Performance
proxy_cache for backend responses, gzip/Brotli compression, and browser caching headers for static assets.
Three distinct performance levers
Nginx has three largely independent tools for making a site faster: caching a reverse-proxied backend's responses so it doesn't have to regenerate them on every request, compressing responses in transit so less data crosses the network, and telling browsers to cache static assets locally so they aren't re-requested at all on a repeat visit. They solve different problems and are commonly all used together in the same config.
proxy_cache: caching backend responses
For a reverse-proxied backend (covered in the earlier Reverse Proxy & Load Balancing page), regenerating the same response for every request is wasteful when that response doesn't change on every request — a product listing page, an API response, anything that's identical for many requests in a row. proxy_cache stores the backend's response and serves it directly from disk on subsequent matching requests, without touching the backend again until the cached copy expires:
proxy_cache_path /var/cache/nginx/proxy_cache levels=1:2 keys_zone=backend_cache:10m max_size=1g inactive=60m;
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_cache backend_cache;
proxy_cache_valid 200 10m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating;
add_header X-Cache-Status $upstream_cache_status;
}
}
proxy_cache_path— declares the cache: where it lives on disk,keys_zone=backend_cache:10mnames it and reserves 10MB of shared memory for cache keys and metadata (not the cached content itself, which lives in the path on disk), andmax_size=1gcaps the on-disk cache size, evicting the least recently used entries once full.proxy_cache backend_cache;— turns caching on for thislocation, using the zone declared above.proxy_cache_valid 200 10m;— cache successful (200) responses for 10 minutes; a separate, usually shorter duration for404responses is common, since you don't want a temporarily-missing resource cached as "missing" for as long as a real page.proxy_cache_use_stale error timeout updating;— if the backend is down, times out, or is in the middle of regenerating a cache entry, serve the last known (stale) cached copy instead of an error — trading a possibly slightly outdated response for actually staying up during a backend hiccup.add_header X-Cache-Status $upstream_cache_status;— exposes whether a response was a cacheHIT,MISS,EXPIRED, orSTALEin the response headers, invaluable for confirming caching is actually working as intended rather than guessing from response times alone.
curl -I https://app.example.com/
# X-Cache-Status: MISS <- first request, backend was hit
curl -I https://app.example.com/
# X-Cache-Status: HIT <- served straight from Nginx's cache, backend untouched
proxy_cache should never be applied blindly to every route — anything personalized (a logged-in user's dashboard, a cart page) or anything that must always reflect the very latest state (a payment confirmation) needs to bypass the cache entirely, typically with proxy_cache_bypass keyed on a cookie or an explicit location exclusion.
Compression: gzip and Brotli
Compressing a text-based response (HTML, CSS, JS, JSON) before sending it over the network can shrink it dramatically, at the cost of some CPU time spent compressing on every request:
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types— which response content types actually get compressed. Already-compressed formats (JPEG, PNG, MP4, ZIP) should be left off this list entirely — compressing already-compressed data wastes CPU for essentially no size reduction, and can occasionally make the response slightly larger.gzip_min_length 1024;— skips compression for responses under 1KB, where the compression overhead isn't worth it relative to the tiny amount of data being sent.gzip_comp_level 5;— a 1-9 trade-off between compression ratio and CPU cost; higher levels shrink the response further but cost more CPU per request, and gains taper off well before 9.
Brotli is a newer compression algorithm (originally from Google) that generally compresses text better than gzip at an equivalent CPU cost, but requires a separate module not compiled into stock Nginx by default (ngx_brotli, either compiled in or available as a package depending on distro):
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml;
Where available, serving Brotli to clients that support it (via the Accept-Encoding header, which Nginx checks automatically) and falling back to gzip for older clients is the common production setup — both directives can coexist in the same config, and Nginx picks whichever the requesting client actually accepts.
Caching static assets in the browser
Beyond backend response caching, Nginx can tell the browser to cache static assets (images, CSS, JS bundles) locally, so a repeat visitor doesn't re-download them at all:
location ~* \.(css|js|jpg|jpeg|png|gif|svg|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
expires 30d;— sets theExpiresheader (and the equivalentCache-Control: max-age), telling the browser it can reuse this exact file for 30 days without even checking back with the server.Cache-Control: public, immutable—publicallows any intermediate cache (a CDN, a shared proxy) to store it too, not just the requesting browser;immutabletells the browser this exact URL will never change its content, so it shouldn't even bother re-validating it before theexpireswindow is up.access_log off;— a minor but common optimization: skips writing a log line for every single static asset request, which can otherwise dominate log volume on an asset-heavy site.
This pattern only works safely when asset filenames actually change whenever their content does — a common convention is a content hash baked into the filename (app.a3f9c1.js), so a long-lived, aggressive cache header is completely safe: the browser caching app.a3f9c1.js forever is fine, because a code change produces app.b7e02d.js, a different URL entirely, forcing a fresh download only for what actually changed.
Common mistakes
- Enabling
proxy_cacheon routes that serve personalized or highly sensitive content without excluding them — a logged-in user's private page can end up cached and served to a completely different user. - Setting a long browser
expiresheader on an asset whose filename never changes when its content does — visitors keep an outdated cached version long after a real update shipped, with no way to know to refresh. - Compressing already-compressed formats (images, videos, archives) via
gzip_types/brotli_types— wasted CPU for negligible or negative size benefit. - Never checking
X-Cache-Status(or equivalent) after configuringproxy_cache— it's easy to assume caching is working from an anecdotally faster page load, when the cache key or bypass conditions are actually misconfigured and every request is aMISS.