Skip to content
LogoLogo

Performance

imgx is designed for low-latency image delivery. This page covers what to expect and how to tune for your workload.

The specific latency numbers previously published on this page were measured against the prior Zig implementation and are not representative of the Rust rewrite (different concurrency model, different encoder call overhead). They've been removed pending a fresh benchmark pass. The qualitative guidance below (what affects cold vs. warm request time, caching strategy, CDN placement) still applies regardless of implementation language. See docs/PARITY.md in the repo for the correctness parity verification done as part of the rewrite.


Latency overview

ScenarioWhat happens
Cache hit (L1 memory)Served directly from in-memory LRU. No origin fetch, no transform.
Cache hit (L2 R2/S3)Fetched from persistent cache, promoted to L1 for subsequent requests.
Cache miss (cold)Full pipeline: origin fetch, transform, encode, cache write.

What affects cold request time

  • Origin fetch — Network round-trip to your image storage. R2 from the same region is typically fastest. HTTP origins depend on the server.
  • Transform complexity — Resize alone is fast. Adding blur, sharpen, or format conversion adds processing time. Animated GIFs with many frames are the most expensive.
  • Image size — A 10 MP JPEG takes longer to decode and resize than a 1 MP thumbnail.
  • Output format — AVIF encoding is slower than JPEG or WebP. PNG compression is moderate.

What keeps warm requests fast

Once an image variant is cached, subsequent requests skip the origin fetch and transform entirely. The L1 memory cache serves responses fastest. The L2 persistent cache (R2/S3) survives restarts and promotes entries to L1 automatically.


Concurrency

imgx runs on axum/tokio: each incoming request is an async task. Two independent limits protect the server from overload, matching the two different resources they bound:

  • IMGX_SERVER_MAX_CONNECTIONS (default 256) bounds total concurrent in-flight requests via a tower concurrency-limit + load-shed layer on the router. At capacity, new requests receive 503 Service Unavailable immediately rather than queueing — the direct equivalent of the prior Zig implementation's bounded thread pool + atomic connection counter, just expressed as router middleware instead of a hand-rolled accept-loop check.
  • CPU-bound libvips work is separately dispatched to tokio::task::spawn_blocking, gated by a semaphore sized to std::thread::available_parallelism() — this bounds concurrent transform work to roughly one per CPU core, which is the actual processing bottleneck, independent of how many connections are open (a burst of cache-hit requests doesn't touch this limiter at all).

Scaling horizontally

For higher throughput, run multiple imgx instances behind a load balancer. Each instance maintains its own L1 memory cache. When R2/S3 caching is enabled, the L2 layer is shared across instances — a variant cached by one instance is available to all others.


Memory allocator

imgx uses jemalloc (via tikv-jemallocator) as the global allocator on all non-MSVC targets, instead of the system allocator. The workload is alloc-heavy and multi-threaded by nature — every transform allocates a fresh image buffer, and requests are served concurrently across tokio worker threads — which is exactly the pattern jemalloc's per-thread arenas are designed for. No configuration is needed; it's compiled in as the process-wide allocator.


Caching strategy

imgx uses a two-tier cache to balance speed and persistence:

TierBackendSpeedPersistenceShared across instances
L1In-memory LRUFastestLost on restartNo
L2R2 / S3FastSurvives restartsYes

Writes are best-effort and non-blocking. On cache misses, imgx still returns the response even when a cache backend skips a write (for example, cache disabled or entry too large). When L2 is enabled, its write happens asynchronously via tokio::spawn, keeping the R2 upload off the response path.

Tuning the L1 cache

SettingDefaultDescription
IMGX_CACHE_MAX_SIZE_BYTES536870912 (512 MiB)Maximum L1 cache size
IMGX_CACHE_DEFAULT_TTL_SECONDS3600 (1 hour)Cache-Control max-age sent to clients

Increase IMGX_CACHE_MAX_SIZE_BYTES if your working set is large and you have memory to spare. The LRU eviction policy keeps the most recently accessed variants in memory.


Putting a CDN in front

For production deployments, put a CDN (Cloudflare, CloudFront, Fastly) in front of imgx. The CDN caches responses at the edge, so most requests never reach your origin server.

Client → CDN (edge cache) → imgx → Origin storage

With a CDN, imgx only handles cache misses — the first request for each unique variant. Subsequent requests for the same URL are served from the CDN edge with single-digit millisecond latency.

imgx sets the right headers for CDN caching out of the box:

  • Cache-Control: public, max-age=<ttl> — tells the CDN how long to cache
  • ETag — enables conditional requests (304 Not Modified)
  • Vary: Accept — ensures separate cache entries per content negotiation result

Benchmarking tips

When testing imgx performance:

  1. Warm the cache first. Hit each URL once, then measure the second request. Cold requests include origin fetch and transform time that won't repeat.
  2. Test with realistic images. A 5 KB test image won't tell you much about production performance with 2 MB photos.
  3. Vary the transforms. Different widths, formats, and effects produce different variants. Each unique combination is a separate cache entry.
  4. Use concurrent requests. imgx handles parallel requests well. Tools like hey, wrk, or k6 can generate realistic concurrent load.
# Single request latency (cold)
curl -w "time_total: %{time_total}s\n" -o /dev/null -s \
  http://localhost:8080/image/w=800,f=webp,q=85/photos/hero.jpg
 
# Single request latency (warm, run twice)
curl -w "time_total: %{time_total}s\n" -o /dev/null -s \
  http://localhost:8080/image/w=800,f=webp,q=85/photos/hero.jpg
 
# Concurrent load test (requires hey: https://github.com/rakyll/hey)
hey -n 200 -c 20 http://localhost:8080/image/w=400,f=auto/photos/hero.jpg

Profiling with samply

For CPU-level profiling (where time actually goes inside a transform, not just end-to-end latency), use samply, a sampling profiler that produces a Firefox Profiler-compatible trace:

cargo install samply
 
# Build with debug symbols but release-level optimizations, kept
# separate from the `release` profile so production images aren't
# bloated with debug sections.
cargo build --profile profiling -p imgx
 
samply record ./target/profiling/imgx
# drive some load against it in another terminal, then Ctrl+C --
# samply opens the trace in your browser automatically.

For a production-like build under the profiler, run it in a container matching the Dockerfile's build (cargo build --profile profiling inside rust:alpine, then copy the binary out) so libvips is linked the same way it is in production.