yum upgrades for production use, this is the repository for you.
Active subscription is required.
Technical Briefing: Automatic WebP/AVIF Conversion in NGINX with ngx_immerse
Today’s source is a GetPageSpeed article about ngx_immerse, an NGINX filter module. When the browser supports WebP or AVIF, the module converts JPEG, PNG and GIF responses on the fly and caches the result. The article covers the problem, how the module works, installation, configuration, testing, performance tuning, security, failure handling and troubleshooting.
The Problem: Modern Image Formats Are Hard to Deploy
Images often make up over half of a page’s weight. WebP and AVIF are 25–50% smaller than JPEG/PNG at equivalent visual quality, but most sites don’t serve them because setup is hard. Each existing approach has a drawback:
- Pre-generating WebP copies doubles storage and complicates builds.
- A separate image proxy such as imgproxy adds infrastructure and latency.
map+try_filesnegotiation works only for static files that already have WebP versions on disk.
How ngx_immerse Works
- Placement: It is a filter module in the NGINX output filter chain, the same mechanism gzip uses.
- Conversion: It converts JPEG, PNG and GIF responses to WebP or AVIF when the browser supports them, then caches the result.
- Non-blocking: Conversion runs in a thread pool, so workers stay non-blocking.
- Any image source: It works with static files,
proxy_pass, FastCGI, and any other source of image responses. - No changes elsewhere: It requires no URL rewriting, no separate service and no application changes.
- Input detection: It identifies formats by magic bytes, not file extensions, so extensionless URLs work.
- Response headers: It returns the correct
Content-Typeand addsVary: Accept.
Conversion Modes
- Lazy (default)
- Serves the original immediately, with zero added latency.
- If the source is file-backed, it queues a background conversion, and the next request gets the cached modern format.
- Best for static files.
- Sync
- Buffers the whole response and converts it in the thread pool, so the event loop is not blocked.
- Serves the converted image on the first request.
- Use it for proxied content.
Accept Header Parsing
The module follows RFC 7231 with full quality-factor support:
| Accept header | Result |
|---|---|
image/avif, image/webp |
AVIF (higher priority in config) |
image/avif;q=0.8, image/webp;q=0.9 |
WebP (higher quality factor) |
image/avif;q=0, image/webp |
WebP (q=0 rejects the format, so it is never served) |
text/html, image/jpeg |
Original image |
Installation
RHEL / CentOS / AlmaLinux / Rocky Linux:
sudo dnf install https://extras.getpagespeed.com/release-latest.rpm
sudo dnf install nginx-module-immerse
Then add this line at the top of /etc/nginx/nginx.conf, before the events block:
load_module modules/ngx_http_immerse_module.so;
Debian / Ubuntu: First set up the matching APT repository, then run:
sudo apt-get update
sudo apt-get install nginx-module-immerse
The module loads automatically on Debian/Ubuntu, so no load_module line is needed.
Required Prerequisites
- A thread pool in the main context:
thread_pool immerse threads=4;Start with one thread per CPU core. Encoding is CPU-bound, so more threads than cores gives no benefit.
-
A cache path at the
httplevel:immerse_cache_path /var/cache/nginx/immerse levels=1:2 max_size=1g;Without it, the module logs an error and passes all images through unchanged.
Configuration
Minimal configuration for static files. AVIF wins when both formats are accepted.
location /images/ {
immerse on;
immerse_thread_pool immerse;
alias /var/www/images/;
}
Proxied content (sync mode):
location /api/images/ {
immerse on;
immerse_mode sync;
immerse_thread_pool immerse;
proxy_pass http://backend;
}
Directives Reference
| Directive | Default | Context | Notes |
|---|---|---|---|
immerse on \| off; |
off |
location | — |
immerse_cache_path path [levels=] [max_size=] [inactive=]; |
none (required) | http | levels defaults to 1:2; max_size defaults to no limit; inactive defaults to 30d; directory is auto-created |
immerse_formats avif webp; |
avif webp |
http/server/location | Priority order; first listed wins on equal q-values. immerse_formats webp; skips AVIF entirely |
immerse_mode lazy \| sync; |
lazy |
location | — |
immerse_webp_quality |
80 |
http/server/location | 75–85 is a good balance |
immerse_avif_quality |
60 |
http/server/location | 50–70 is typical |
immerse_min_size |
1k |
http/server/location | Skips favicons and tracking pixels |
immerse_max_size |
10m |
http/server/location | Prevents resource exhaustion |
immerse_thread_pool name; |
default |
http/server/location | Must match a thread_pool directive |
immerse_x_header on \| off; |
on |
http/server/location | Emits X-Immerse: hit / miss / error |
The article’s full configuration example uses:
max_size=2g inactive=60don the cache pathimmerse_webp_quality 82andimmerse_avif_quality 63- Per-location settings:
/images/withimmerse_min_size 2kandimmerse_max_size 5m- WebP-only thumbnails at quality 75
- A CDN-facing location with
immerse_x_header off
Testing
- Check the configuration with
nginx -t. Look for “syntax is ok” and “test is successful”. - Send a WebP-capable request:
curl -s -D- -H "Accept: image/webp,image/jpeg" http://localhost/images/photo.jpg -o /dev/null- In lazy mode, the first request returns
Content-Type: image/jpegwithX-Immerse: miss. - Later requests return
image/webpwithX-Immerse: hit.
- In lazy mode, the first request returns
- Test the fallback with
-H "Accept: image/jpeg". You should get the original image, still withVary: Accept. - Inspect the cache with
find /var/cache/nginx/immerse -type f -lsand look for.webpand.aviffiles.
Performance
- CPU cost: A JPEG-to-WebP conversion takes roughly 10–50ms.
- Thread pool sizing:
- Under 100 images/s: 2–4 threads
- 100–1000 images/s: 4–8 threads
- Over 1000 images/s: use lazy mode and let the cache absorb the load
- CDN caveat: In lazy mode behind a CDN, the CDN caches the original format first. Modern formats only start flowing after that CDN cache expires.
- Cache warming: Crawl your sitemap with a WebP-capable Accept header:
curl -s https://example.com/sitemap.xml | grep -oP '<loc>[^<]+\.(jpg|png)</loc>' | sed 's/<\/?loc>//g' | xargs -P4 -I{} curl -s -H "Accept: image/webp" -o /dev/null {} - Automatic invalidation: The cache key includes the source file’s
mtime, so updating an image invalidates its conversions.- To clear everything manually, run
rm -rf /var/cache/nginx/immerse/*. - No reload is needed, because the module recreates the subdirectories.
- To clear everything manually, run
- Memory: Sync mode buffers the whole image, up to the
immerse_max_sizecap. On low-memory servers, lower the cap, e.g.immerse_max_size 2m;.
Security
- Hide module state: Set
immerse_x_header off;in production so clients can’t see internal module state. - Set size limits: For example,
immerse_min_size 1k;andimmerse_max_size 5m;. - Restrict cache directory permissions:
sudo mkdir -p /var/cache/nginx/immerse
sudo chown nginx:nginx /var/cache/nginx/immerse
sudo chmod 700 /var/cache/nginx/immerse
Failure Handling: “Never Break What Works”
| Condition | Behavior |
|---|---|
| Codec error | Serve original, log error |
| Corrupt or truncated input | Serve original, log error |
| Cache write fails (e.g. full disk) | Serve converted image from memory, log warning |
| Size outside min/max limits | Pass through unchanged |
| No modern format in Accept | Pass through unchanged |
| Unknown image format | Pass through unchanged |
| Thread pool not found | Fall back to synchronous conversion |
The module never returns a 500 because of a conversion failure.
CDN Compatibility
When the module is active, it adds Vary: Accept to every response, including pass-through responses. This stops a CDN from serving WebP to a JPEG-only client. Confirm that your CDN respects Vary. The article says Cloudflare, Fastly and AWS CloudFront do.
Comparison with Other Approaches
| Feature | ngx_immerse | Pre-generated WebP | imgproxy | PageSpeed |
|---|---|---|---|---|
| AVIF support | Yes | No | Yes | No |
Works with proxy_pass |
Yes | No | Yes | Yes |
| Requires URL changes | No | No | Yes | No |
| Separate service needed | No | No | Yes | No |
| Thread pool integration | Yes | N/A | N/A | No |
| Built-in caching | Yes | Manual | External | Yes |
| Input format detection | Magic bytes | File extension | File extension | Mixed |
| Quality factor parsing | RFC 7231 | Basic | N/A | Basic |
Troubleshooting
- Images not converted:
- Check that the client sends
image/webporimage/avif. - Check that the image is within the 1 KB / 10 MB default limits.
- Confirm that
immerse_cache_pathis declared and writable. - Look for
immerse:-prefixed lines in the error log. - Enable
X-Immerseand check its value.
- Check that the client sends
- Empty cache:
- Check write permissions for the worker user.
- Remember that in lazy mode, the second request is the one that gets the conversion.
- Check free disk space.
- High CPU:
- Reduce the thread count.
- Raise
immerse_min_size. - Lower
immerse_max_size. - Switch to lazy mode.
Practical Payoff
A few lines of NGINX configuration give you WebP and AVIF delivery for static, proxied and FastCGI images. You need no conversion pipeline, no extra service and no application changes. The module handles content negotiation, caching and cache invalidation, and it falls back to the original image on any failure. The article notes that for simple static-only WebP needs, map + try_files remains a lighter alternative.
Read the full article: NGINX Automatic Image Conversion: WebP and AVIF