Skip to content

immerse: NGINX modern image format filter module

Debian/Ubuntu installation

These docs apply to the APT package nginx-module-immerse provided by the GetPageSpeed Extras repository.

  1. Configure the APT repository as described in APT repository setup.
  2. Install the module:
sudo apt-get update
sudo apt-get install nginx-module-immerse
Show suites and architectures
| Distro   | Suite             | Component   | Architectures   |
|----------|-------------------|-------------|-----------------|
| debian   | bookworm          | main        | amd64, arm64    |
| debian   | bookworm-mainline | main        | amd64, arm64    |
| debian   | trixie            | main        | amd64, arm64    |
| debian   | trixie-mainline   | main        | amd64, arm64    |
| ubuntu   | jammy             | main        | amd64, arm64    |
| ubuntu   | jammy-mainline    | main        | amd64, arm64    |
| ubuntu   | noble             | main        | amd64, arm64    |
| ubuntu   | noble-mainline    | main        | amd64, arm64    |

NGINX filter module for transparent modern image format delivery. Intercepts image responses from any source (static files, proxy_pass, FastCGI, etc.) and converts them to WebP or AVIF based on client Accept headers. No URL rewriting, no separate service, no application changes.

How It Works

ngx_immerse inserts into the NGINX filter chain. When a response with Content-Type: image/jpeg, image/png, or image/gif passes through, the module checks the client's Accept header for modern format support. If a match is found, it either serves a cached conversion or triggers one via a thread pool - keeping worker processes non-blocking.

Client Request                    NGINX
     |                              |
     |--- GET /photo.jpg ---------> |
     |    Accept: image/avif,       |
     |    image/webp                |
     |                              |--- upstream / static file
     |                              |<-- image/jpeg response
     |                              |
     |                         [ngx_immerse]
     |                              |--- cache hit? serve cached avif
     |                              |--- cache miss + lazy? serve jpeg,
     |                              |    queue background conversion
     |                              |--- cache miss + sync? convert in
     |                              |    thread pool, serve avif
     |                              |
     |<-- 200 image/avif ---------- |
     |    Vary: Accept              |

Features

  • Transparent format negotiation - parses Accept header per RFC 7231 with quality factor support (q=0 rejects, highest q wins)
  • WebP and AVIF output - configurable quality, priority order, and conditional compilation (build with only one if desired)
  • File-based cache - MD5-keyed with source mtime in the key, so cache invalidates automatically when the original image changes
  • Two conversion modes - lazy (serve original now, convert in background) and sync (convert inline, serve modern format immediately)
  • Thread pool integration - conversions run in NGINX thread pools, keeping the event loop free
  • Graceful fallback - conversion failure, corrupt images, missing thread pool, full disk: always serves the original, never returns 500
  • Size thresholds - skip images below immerse_min_size or above immerse_max_size to avoid wasting CPU on tiny icons or huge assets
  • CDN-safe - adds Vary: Accept so caches and CDNs don't serve the wrong format to the wrong client
  • Debug header - X-Immerse: hit|miss|error|pass shows what happened (toggleable)
  • Magic byte detection - identifies input format by file signature, not URL extension

Configuration

Minimal example

thread_pool immerse threads=4;

http {
    immerse_cache_path /var/cache/nginx/immerse levels=1:2 max_size=1g;

    server {
        listen 80;

        location /images/ {
            immerse on;
            immerse_thread_pool immerse;
            alias /var/www/images/;
        }
    }
}

With proxied content

thread_pool immerse threads=4;

http {
    immerse_cache_path /var/cache/nginx/immerse levels=1:2 max_size=1g;

    server {
        listen 80;

        location /api/photos/ {
            immerse on;
            immerse_mode sync;
            immerse_thread_pool immerse;
            proxy_pass http://backend;
        }
    }
}

Full example with all directives

thread_pool immerse threads=4;

http {
    immerse_cache_path /var/cache/nginx/immerse levels=1:2
                       max_size=2g inactive=60d;

    # Defaults for all locations
    immerse_formats avif webp;
    immerse_webp_quality 82;
    immerse_avif_quality 63;

    server {
        listen 80;

        # Static images - lazy mode (default)
        location /images/ {
            immerse on;
            immerse_thread_pool immerse;
            immerse_min_size 2k;
            immerse_max_size 5m;
            alias /var/www/images/;
        }

        # Proxied images - sync mode for immediate conversion
        location /api/photos/ {
            immerse on;
            immerse_mode sync;
            immerse_thread_pool immerse;
            proxy_pass http://backend;
        }

        # WebP only (no AVIF)
        location /thumbnails/ {
            immerse on;
            immerse_formats webp;
            immerse_webp_quality 75;
            immerse_thread_pool immerse;
            alias /var/www/thumbs/;
        }

        # Disable debug header in production
        location /cdn/ {
            immerse on;
            immerse_x_header off;
            immerse_thread_pool immerse;
            alias /var/www/cdn/;
        }
    }
}

Directives Reference

immerse

Syntax: immerse on | off; Default: off Context: location

Enables or disables image format conversion for the location. When enabled, the module intercepts image responses and attempts conversion based on client support.

Requires immerse_cache_path to be set at the http level. If the cache path is not configured, the module logs an error and passes the response through unchanged.

immerse_cache_path

Syntax: immerse_cache_path path [levels=levels] [max_size=size] [inactive=time]; Default: none (required when immerse is enabled) Context: http

Sets the cache directory and parameters. This directive is required - the module will not convert images without a configured cache path.

Parameters:

  • path - filesystem directory for cached conversions. Created automatically if it does not exist.
  • levels - subdirectory hierarchy depth, specified as colon-separated digits (1 or 2). Default: 1:2. With levels=1:2, a cache key a3b1c4d5e6... is stored at path/a/3b/a3b1c4d5e6....webp.
  • max_size - maximum total cache size. Accepts size suffixes (k, m, g). Default: unset (no limit).
  • inactive - time after which unused cached files are eligible for removal. Accepts time suffixes (s, m, h, d). Default: 30d.
immerse_cache_path /var/cache/nginx/immerse levels=1:2 max_size=1g inactive=30d;

immerse_formats

Syntax: immerse_formats format ...; Default: avif webp Context: http, server, location

Sets the preferred output formats in priority order. When multiple formats are accepted by the client with equal quality factors, the first format listed here wins.

Valid formats: avif, webp. At least one must be supported at compile time.

## Prefer WebP over AVIF
immerse_formats webp avif;

## WebP only
immerse_formats webp;

immerse_mode

Syntax: immerse_mode lazy | sync; Default: lazy Context: location

Sets the conversion strategy for cache misses.

lazy - serves the original image immediately with no latency overhead. If the image source is file-backed, queues a background conversion in the thread pool. The converted variant is available for subsequent requests. Best for static file serving where first-request latency matters.

sync - buffers the entire response body, converts it in a thread pool, and serves the converted image in the same request. The worker is not blocked (the thread pool handles the work). Best for proxied content or when you want every response to be in a modern format.

## Static files - lazy is fine, cache warms quickly
location /images/ {
    immerse on;
    immerse_mode lazy;
}

## API responses - sync ensures modern format on first request
location /api/photos/ {
    immerse on;
    immerse_mode sync;
    proxy_pass http://backend;
}

immerse_webp_quality

Syntax: immerse_webp_quality quality; Default: 80 Context: http, server, location

WebP encoding quality (1-100). Higher values produce better visual quality at larger file sizes. Values around 75-85 provide a good balance for most content.

immerse_avif_quality

Syntax: immerse_avif_quality quality; Default: 60 Context: http, server, location

AVIF encoding quality (1-100). AVIF achieves good visual quality at lower numeric values than WebP or JPEG. Values around 50-70 are typical for web delivery. The encoder uses speed 6 (balanced speed/quality).

immerse_min_size

Syntax: immerse_min_size size; Default: 1k (1024 bytes) Context: http, server, location

Minimum response body size for conversion. Images smaller than this are passed through unchanged. This avoids wasting CPU on tiny images (favicons, 1x1 tracking pixels) where format conversion provides negligible savings.

immerse_max_size

Syntax: immerse_max_size size; Default: 10m (10485760 bytes) Context: http, server, location

Maximum response body size for conversion. Images larger than this are passed through unchanged. This prevents resource exhaustion from very large images that would consume significant memory and CPU during decoding/encoding.

immerse_thread_pool

Syntax: immerse_thread_pool name; Default: default Context: http, server, location

Name of the NGINX thread pool to use for conversion tasks. Must match a thread_pool directive in the main configuration context.

## Define a dedicated pool
thread_pool immerse threads=4;

http {
    server {
        location /images/ {
            immerse on;
            immerse_thread_pool immerse;
        }
    }
}

Sizing guidance: start with the number of CPU cores. Image encoding is CPU-bound, so more threads than cores provides no benefit. If the same server handles other thread pool work (aio), consider a dedicated pool for immerse.

immerse_x_header

Syntax: immerse_x_header on | off; Default: on Context: http, server, location

Controls the X-Immerse response header. When enabled, every processed response includes a header indicating what happened:

Value Meaning
hit Served from cache
miss Cache miss; converted (sync) or original served (lazy)
error Conversion failed; original served as fallback

Disable this in production if you don't want to expose internal module state to clients.

Response Headers

When ngx_immerse processes a response, it modifies or adds the following headers:

Header Value When
Content-Type image/webp or image/avif Converted or served from cache
Content-Length Size of converted image Converted or served from cache
Vary Accept Always (even on pass-through) when module is active
X-Immerse hit, miss, or error When immerse_x_header is on

The Vary: Accept header is critical for correct CDN behavior. Without it, a CDN might cache a WebP response and serve it to a client that only supports JPEG.

Accept Header Parsing

The module parses the Accept request header per RFC 7231:

  • Extracts image/webp and image/avif entries with their quality factors
  • q=0 means the client explicitly rejects that format
  • q=1 (or no q parameter) means full support
  • The format with the highest q value is selected
  • On equal q, the first format in immerse_formats wins
  • If neither format is present or both have q=0, the original is served

Examples:

Accept header Result (with default immerse_formats avif webp)
image/avif, image/webp AVIF (first in config, equal q)
image/webp WebP
image/avif;q=0.8, image/webp;q=0.9 WebP (higher q)
image/avif;q=0, image/webp WebP (AVIF rejected)
text/html, image/jpeg Original (no modern format)

Input Format Detection

Source images are identified by magic bytes in the response body, not by file extension:

Format Magic bytes
JPEG FF D8 FF
PNG 89 50 4E 47 0D 0A 1A 0A
GIF GIF87a or GIF89a

Images already in WebP or AVIF format are passed through unchanged. Animated GIFs (multiple frames) are also passed through.

Cache

How it works

The cache stores converted images in a directory hierarchy keyed by MD5 hash. The hash input is URI + source_mtime + target_format + quality, so:

  • Different formats (WebP, AVIF) get separate cache entries
  • Changing quality settings produces new cache entries
  • Modifying the original image (changing its mtime) automatically invalidates the cached conversion

Directory layout

With levels=1:2, a cache key a3b1c4d5... produces:

/var/cache/nginx/immerse/a/3b/a3b1c4d5e6f7890123456789abcdef01.webp

Atomic writes

Cache files are written atomically: data goes to a temporary file first, then rename() moves it into place. This prevents serving partially-written files under concurrent load.

Cache warming

In lazy mode, the first request for an image serves the original. The conversion runs in the background, and subsequent requests get the cached modern format. In sync mode, the very first request triggers conversion and serves the result.

Manual cache clearing

To clear the entire cache:

rm -rf /var/cache/nginx/immerse/*

No NGINX reload is required. The module will recreate directories as needed.

Error Handling

ngx_immerse follows a strict "never break what already works" policy:

Condition Behavior
Conversion fails (codec error) Serve original, log error
Cache write fails (disk full, permissions) Serve converted from memory, log warning
Corrupt or truncated input image Serve original, log error
Image below min_size or above max_size Pass through unchanged
No modern format in client Accept Pass through unchanged, add Vary: Accept
Thread pool not found Fall back to synchronous (blocking) conversion
immerse_cache_path not configured Pass through unchanged, log error
Unknown image format (not JPEG/PNG/GIF) Pass through unchanged

The module will never return a 500 error due to a conversion failure.

Architecture

Source files

File Purpose
config NGINX build system integration, library detection
src/ngx_http_immerse_common.h Shared types, constants, function declarations
src/ngx_http_immerse_module.c Module entry point, directives, configuration lifecycle
src/ngx_http_immerse_filter.c Header and body filter chain, state machine, thread pool dispatch
src/ngx_http_immerse_convert.c Image decode/encode engine (runs in thread pool)
src/ngx_http_immerse_cache.c Cache key generation, lookup, atomic store
src/ngx_http_immerse_accept.c RFC 7231 Accept header parser
src/ngx_http_immerse_util.c Response sending, body buffering, format detection, header helpers

Thread safety

All image conversion work runs in NGINX thread pool workers. The conversion code (ngx_http_immerse_convert.c) uses only malloc/free, POSIX file I/O, and image library calls. It never accesses NGINX shared state, request pools, or the event loop.

Results are passed back to the main event loop via the standard NGINX thread task completion mechanism (ngx_thread_task_t).

State machine

The body filter uses a phase-based state machine:

START -> READ -> CONVERT -> SEND -> DONE     (sync mode)
PASS -> DONE                                  (lazy mode, first request)
SERVE_CACHE -> DONE                           (cache hit)

Testing

## Run all tests (HUP mode for ~10x faster iteration)
make tests

## Run a specific test file
make tests T=t/sync.t

## Run without HUP mode (cleaner state between tests)
make tests HUP=0

## Interactive shell for debugging
make shell

## Rebuild base image (after Dockerfile changes)
make base-image

## Test against a different NGINX version
make tests NGINX_VERSION=release-1.26.2

CI

GitHub Actions runs tests against NGINX 1.26.2, 1.27.3, and 1.28.0 on every push and pull request.

Test suite

File Coverage
t/accept.t Accept header parsing, q-values, format selection
t/sync.t Sync mode conversion for JPEG, PNG, GIF to WebP/AVIF
t/lazy.t Lazy mode: original served first, cache populated after
t/cache.t Cache hit/miss behavior
t/limits.t min_size and max_size filtering
t/fallback.t Corrupt image fallback
t/passthrough.t Module disabled, non-image content, no Accept header
t/config.t Directive validation, x_header toggle
t/vary.t Vary: Accept header presence

Debugging

  • Check test-error.log in the repo root for NGINX debug output
  • Use make shell to enter the container and run tests manually
  • Log level is set to debug in the Docker test environment