Documentation

This page has two kinds of numbers:

  • HTTP benchmarks — tileserver-rs and its alternatives run in Docker and serve the same fixtures under the same load. These are the numbers to compare servers with.
  • In-process benchmarks — criterion micro-benchmarks of single hot functions (further down). These show where time goes inside tileserver-rs; they do not compare servers.

Test environment

Run captured 2026-10-16 against tileserver-rs:latest built from main at commit cff03152 (after v2.38.0, with CEL filtering and the LLM proxy).

  • Hardware: Apple Silicon MacBook, 12 cores, Docker Desktop (ARM64 native images)
  • Load tool: autocannon, 100 connections, 10 s per case (STAC: 20 connections, 30 s warmup)
  • Competitors: ghcr.io/maplibre/martin:latest, maptiler/tileserver-gl:latest, ghcr.io/developmentseed/titiler:latest
  • Quiet host: each group started only after benchmarks/scripts/run-quiet.sh measured the host as idle for 15 s (non-Docker processes under 120 % of one core in total, benchmark containers under 20 %, at least 25 % memory free). The measured load before and after every group is in the run's stability.log.
  • Errors: 0 non-2xx responses in every case below.
Warning

The load client runs on the same machine as the servers, so absolute req/s values are machine-specific. Compare the ratios between servers, not the raw numbers with another machine. After the OGC and STAC groups the host check saw a node process at 113–155 % CPU; those two groups started on a quiet host but may have been disturbed while they ran.

Test data

SourceFileAreaZoom RangeSize
PMTilesprotomaps-sample.pmtilesFlorence, Italy0-156.3 MB
MBTileszurich_switzerland.mbtilesZurich, Switzerland0-1434 MB
PostgreSQLbenchmark_points tableZurich, Switzerland0-1450,000 points
COGbenchmark-rgb.cog.tifWorld (Web Mercator)0-318 MB
DEMtest-dem.cog.tifSan Francisco Bay9-120.9 MB

The world COG is built by benchmarks/scripts/make-benchmark-cog.sh (Natural Earth, reprojected to EPSG:3857, JPEG, 5 overviews). It is not the 90 MB file used before October 2026, so COG numbers do not compare with older runs.

Summary

Average over all zoom levels of a group:

Grouptileserver-rsmartintileserver-gltitilerResult
PMTiles (vector)1,336 req/s · 83 ms1,615 req/s · 71 ms1,280 req/s · 90 ms—martin ahead — but see encoding
MBTiles (vector)752 req/s · 190 ms856 req/s · 161 ms692 req/s · 208 ms—martin ahead — but see encoding
Same tile, same encoding1,475 req/s (br)1,477 req/s (br)gzip only—tie
PostGIS table1,339 req/s · 514 ms1,244 req/s · 505 ms——tileserver-rs +8 %
PostGIS function1,447 req/s · 184 ms1,502 req/s · 167 ms——martin +4 %
COG (raster)337 req/s · 293 ms——96 req/s · 1,016 mstileserver-rs 3.5×
DEM terrain-RGB276 req/s · 410 ms——146 req/s · 743 mstileserver-rs 1.9×
Raster style render152 req/s · 68 ms—204 req/s · 49 ms—tileserver-gl +34 %
OGC API Features11,342 req/s · 11 ms———no competitor
Info

Honest assessment. tileserver-rs leads on COG, DEM and PostGIS tables, and ties martin when both serve the same bytes and on PostGIS function sources. It is behind on native raster style rendering (tileserver-gl is 34 % faster) and, in grid mode, on PostGIS (martin is about 2× faster). Both gaps are open performance work.

Read this before comparing PMTiles and MBTiles

The stored tiles are gzip-compressed. The PMTiles and MBTiles groups send no Accept-Encoding header:

  • tileserver-rs and tileserver-gl send the stored gzip tile (Florence z12: 58 KB, content-encoding: gzip).
  • martin decompresses and sends the raw tile (Florence z12: 82 KB, no content-encoding).

So these two groups do not measure the same work. The compression group sends an explicit Accept-Encoding to every server, so it compares equal work:

Encodingtileserver-rsmartintileserver-gl
identity (82.93 KB)1,867 req/s · 52.9 ms1,866 req/s · 52.8 mssends gzip anyway
br (60.5 KB)1,475 req/s · 70.3 ms1,477 req/s · 68.5 mssends gzip anyway
zstd (64.6 KB)1,386 req/s · 73.5 ms1,395 req/s · 72.0 mssends gzip anyway
gzip (61.8 KB)1,388 req/s · 73.3 ms1,462 req/s · 70.4 ms1,407 req/s · 72.7 ms

At the same encoding tileserver-rs and martin are within 1 % (gzip: 5 %). tileserver-rs caches brotli and zstd variants per (source, z, x, y, encoding), so re-encoding cost is paid once per tile. The April 2026 result where martin fell to 107 req/s on brotli does not hold with the current martin image.

Results by zoom level

Requests/s, average latency and P99 latency per case.

PMTiles (Florence, Italy)

Zoomtileserver-rsmartintileserver-gl
z101,288 · 82 ms · p99 162 ms1,618 · 61 ms · p99 394 ms1,218 · 84 ms · p99 145 ms
z111,443 · 71 ms · p99 138 ms1,818 · 54 ms · p99 342 ms1,457 · 70 ms · p99 123 ms
z121,551 · 66 ms · p99 128 ms1,900 · 52 ms · p99 311 ms1,539 · 66 ms · p99 117 ms
z131,440 · 72 ms · p99 142 ms1,885 · 52 ms · p99 321 ms1,439 · 71 ms · p99 127 ms
z141,580 · 66 ms · p99 106 ms1,816 · 54 ms · p99 320 ms1,485 · 68 ms · p99 114 ms
z15711 · 142 ms · p99 259 ms651 · 151 ms · p99 583 ms541 · 183 ms · p99 254 ms

martin has the best average latency, but its P99 is 2–3× higher than tileserver-rs at every zoom (z14: 320 ms vs 106 ms). At z15 tileserver-rs leads on all three numbers.

MBTiles (Zurich, Switzerland)

Zoomtileserver-rsmartintileserver-gl
z10591 · 184 ms · p99 328 ms686 · 144 ms · p99 794 ms570 · 186 ms · p99 320 ms
z11890 · 118 ms · p99 202 ms1,086 · 91 ms · p99 360 ms828 · 120 ms · p99 182 ms
z121,058 · 100 ms · p99 195 ms1,134 · 87 ms · p99 357 ms959 · 103 ms · p99 142 ms
z13989 · 109 ms · p99 209 ms1,130 · 88 ms · p99 356 ms915 · 110 ms · p99 183 ms
z14233 · 440 ms · p99 699 ms245 · 395 ms · p99 1,233 ms189 · 523 ms · p99 913 ms

Same pattern as PMTiles: martin leads the average, tileserver-rs leads the tail.

PostGIS table (benchmark_points, 50,000 points)

PostGIS 3.6.4 on PostgreSQL 16.15, built from source by benchmarks/postgres/Dockerfile.

Zoomtileserver-rsmartin
z1066 · 1,369 ms · p99 6,903 ms71 · 1,296 ms · p99 3,052 ms
z11119 · 794 ms · p99 4,190 ms119 · 801 ms · p99 2,311 ms
z12312 · 313 ms · p99 1,282 ms297 · 327 ms · p99 1,058 ms
z131,286 · 77 ms · p99 323 ms1,237 · 80 ms · p99 404 ms
z144,914 · 20 ms · p99 23 ms4,497 · 22 ms · p99 25 ms

Both servers are bound by PostGIS (ST_AsMVT) at low zoom. tileserver-rs is ahead at z12–z14; its P99 at z10–z11 is worse than martin's.

PostGIS function source

Re-run after the function-source cache fix (both servers in the same run). "Before" is the first run, where tileserver-rs sent every function-source request past its tile cache.

Zoomtileserver-rsmartintileserver-rs before fix
z10364 · 269 ms · p99 1,536 ms407 · 241 ms · p99 804 ms179 · 541 ms
z11381 · 256 ms · p99 1,323 ms396 · 247 ms · p99 770 ms159 · 608 ms
z12339 · 289 ms · p99 1,310 ms391 · 251 ms · p99 780 ms147 · 654 ms
z131,159 · 85 ms · p99 559 ms1,297 · 76 ms · p99 327 ms191 · 511 ms
z144,991 · 20 ms · p99 23 ms5,021 · 19 ms · p99 23 ms215 · 455 ms

With the cache fix tileserver-rs is within 4 % of martin on average (1,447 vs 1,502 req/s), up from 8× behind. Its P99 at z10–z12 is still about 2× martin's; that is open work. Requests that pass query parameters to the function still skip the cache, because the function output can depend on them.

COG (world RGB, JPEG overviews)

Zoomtileserver-rstitiler
z0347 · 286 ms119 · 780 ms
z1345–370 · 267–285 ms95–98 · 957–986 ms
z2324–329 · 299–304 ms66–78 · 1,200–1,395 ms
z3308 · 319 ms122 · 776 ms

DEM terrain-RGB (Terrarium)

Zoomtileserver-rstitiler (algorithm=terrarium)
z9402 · 245 ms225 · 436 ms
z10238 · 411 ms169 · 568 ms
z11143 · 674 ms90 · 1,059 ms
z12318 · 309 ms101 · 911 ms

Encoding is byte-accurate: a round-trip decode reproduces source elevation within the encoding interval (≤0.1 m Mapbox-RGB, ≤1/256 m Terrarium).

Raster style rendering (protomaps-light, Florence)

Zoomtileserver-rstileserver-gl
z10187 · 53 ms205 · 48 ms
z11197 · 50 ms230 · 43 ms
z12148 · 67 ms214 · 46 ms
z13112 · 88 ms201 · 49 ms
z14119 · 84 ms172 · 58 ms

Both use MapLibre Native. tileserver-gl is faster from z12 up.

OGC API Features

Casetileserver-rs
single feature23,082 req/s · 3.8 ms
countries bbox14,362 req/s · 6.5 ms
cities CQL2 filter7,242 req/s · 13.3 ms
cities items (100)5,212 req/s · 18.7 ms

STAC

The STAC group ran, but its results are not a valid comparison: the static Sentinel-2 tiles from tileserver-rs were ~2 KB blank PNGs (the max_items = 1 scene does not cover the requested tiles), and titiler's responses averaged ~3 KB. The STAC fixture needs fixing before these numbers mean anything.

Running benchmarks

# Build the image from your checkout
docker build -t tileserver-rs:latest .

# Start every server (tileserver-rs, martin, tileserver-gl, titiler, PostGIS)
docker compose -f benchmarks/docker-compose.yml --profile all up -d

# Build the world COG fixture once (needs GDAL)
benchmarks/scripts/make-benchmark-cog.sh

# Run all groups, each only when the host is quiet
benchmarks/scripts/run-quiet.sh

# Turn a run into markdown tables
node benchmarks/scripts/summarize-run.mjs benchmarks/results/<timestamp>

Run single groups directly with the harness:

cd benchmarks
node run-benchmarks.js --type pmtiles            # or mbtiles, postgres, postgres_function,
                                                 # cog, dem, raster, ogc, stac, compression
node run-benchmarks.js --type cog --server tileserver-rs
node run-benchmarks.js --duration 30 --connections 200
node run-benchmarks.js --markdown

With pnpm 12, pass flags without --: pnpm --filter @tileserver-rs/benchmarks run bench --type pmtiles.

Quit heavy applications first and check stability.log after the run. A result measured on a busy host is not a valid result.

Feature comparison

Featuretileserver-rstileserver-glmartintitiler
LanguageRustNode.jsRustPython
PMTiles / MBTiles✅✅✅❌
MLT (MapLibre Tiles)✅❌✅❌
PostGIS✅❌✅❌
COG / DEM✅❌❌✅
STAC mosaics✅❌❌◐
Raster style render✅ Native✅ Native❌❌
OGC API Features✅❌❌❌
CEL feature filters✅❌❌❌
Static map images✅✅❌❌

Optimization Tips

  1. Use release builds - cargo build --release is 10-50x faster than debug
  2. Use PMTiles for production - cloud-native, HTTP range request friendly
  3. Use MBTiles for development - easy to generate and inspect with SQLite tools
  4. Enable compression - gzipped tiles reduce bandwidth significantly
  5. Use CDN caching - tiles are immutable, cache with long TTLs
  6. Match connections to cores - more connections than CPU cores adds overhead
  7. For COG serving - use internal tiling and overviews for faster access

Raster fast-path (STAC mosaic, band math)

Added in the raster fast-path stack (commits #1-#8 merged into main 2026-04-19). Measured on a 12-core Apple M2 / 16 GB with the raster feature enabled and the default CPU baseline (x86-64-v3 via the -fast Docker tier rebuilds these with AVX-512 / SVE2).

Reproduce with:

cargo bench --bench raster --features raster
BenchmarkMedian timeWhat it measures
raster_encode_256/png216 µsRGBA → PNG encode at the HTTP boundary.
raster_encode_256/jpeg_q85715 µsRGBA → JPEG q=85.
raster_encode_256/webp_lossless280 µsRGBA → lossless WebP.
raster_decode_png_256131 µsPNG → RasterImage (band-math ingest path).
raster_mosaic_5_assets_256/first115 µs5-layer mosaic, first method. Short-circuits once canvas is opaque, so it's 5-20× faster than stats methods.
raster_mosaic_5_assets_256/highest636 µsPer-pixel max over 5 layers.
raster_mosaic_5_assets_256/lowest644 µsPer-pixel min over 5 layers.
raster_mosaic_5_assets_256/mean1.38 msPer-pixel arithmetic mean.
raster_mosaic_5_assets_256/median2.58 msPer-pixel median (retains all layers, sorts per-pixel). Heaviest; use only when outlier robustness matters.
raster_mosaic_5_assets_256/stdev1.49 msPer-pixel standard deviation.
raster_mosaic_5_assets_256/count214 µsPer-pixel count of valid contributions.
raster_band_math_ndvi_2561.52 msPure-Rust NDVI (b2-b1)/(b2+b1) over a 256×256 two-band raster.

Comparison to titiler

Titiler's own benchmarks (from titiler#321 and rio-tiler's internal criteria tests) place equivalent in-memory operations in the same order of magnitude: ~1-3 ms for NDVI on a 256×256 tile, dominated by numpy+numexpr overhead. tileserver-rs lands in the same envelope with exmex (pure-Rust, ~1.5 ms) without the Python import cost; switching to an AVX-512-targeted build (:fast Docker tier) cuts these further on servers with the right CPU.

Why COG serving is fast

The COG results in the HTTP table above come mainly from overview pyramid selection: GDAL is opened with OVERVIEW_LEVEL=N, so the warp reads a pre-computed overview instead of the full-resolution raster. Other contributors: Moka-cached Dataset handles, parallel mosaic compositing with futures::join_all, and the RasterImage ndarray pipeline that keeps pixel values as floats through the mosaic layer.

What was not yet benchmarked

  • Cold-path STAC /search + /vsicurl/ COG fetch: dominated by network RTT, not CPU. Typical 500-2000 ms on Element84 Earth Search; this is not part of the raster fast-path scope.
  • Full mosaic pipeline with real COG reads: requires a live S3 COG and a local network, so the cold-path numbers are deferred to benches/stac_live.rs in a follow-up commit.
  • GDAL Dataset moka cache hit rate: qualitative observation on demo.tileserver.app shows warm-path tiles dropping from ~2 s to ~150 ms after the cache lands, matching the arithmetic (~30 ms × 5 asset re-opens = 150 ms saved per tile).

Style-rewrite hot path

rewrite_style_for_api runs once on every GET /styles/{id}/style.json and again before every native-render request — it converts relative tile/source URLs to absolute, forwards ?key= query strings, and injects the MLT encoding hint where applicable.

Reproduce with:

cargo bench --bench styles
BenchmarkMedian timeStyle fixture
style_rewrite/tiny_no_key789 ns1 source, 0 layers — synthetic minimal style
style_rewrite/tiny_with_key1.04 µsAdds ?key=… rewriting cost to every URL
style_rewrite/protomaps_light_no_key7.37 µsReal-world Protomaps Light (4 sources, 80 layers)
style_rewrite/protomaps_light_with_key7.78 µsSame style with ?key= forwarding

Sub-10 µs per call confirms style rewriting is not on the critical path of perceived latency — it's at least three orders of magnitude faster than the actual tile fetch (1-10 ms range).

MVT→MLT transcode (native mlt-core 0.11 reader)

When a client requests .mlt from an MVT source, mvt_to_mlt transcodes on the fly. As of mlt-core 0.11 this uses the native mvt::mvt_to_tile_layers reader, which decodes MVT straight into row-oriented TileLayers — replacing the previous hand-rolled GeoJSON FeatureCollection bridge with its own column-type inference.

Reproduce with:

cargo bench --features mlt --bench mlt -- mvt_to_mlt_transcode
ZoomBridge (before)Native (after)ChangeThroughput
z015.59 ms16.67 ms+6.95%5.06 → 4.73 MiB/s
z4184.6 ms142.3 ms−22.9%9.51 → 12.34 MiB/s
z7230.2 ms183.2 ms−20.4%7.65 → 9.61 MiB/s
z1319.72 ms15.89 ms−19.5%23.68 → 29.40 MiB/s

Real-data tiles (z4/z7/z13) transcode ~20-23% faster because the native reader skips the intermediate GeoJSON allocation and hand-rolled type inference. The trivial z0 world-overview tile regresses ~7% — its near-empty payload is dominated by the reader's fixed setup cost, so there is nothing to amortise. All deltas are statistically significant (p < 0.05). Both sides run mlt-core 0.11.0, so this isolates the reader change rather than the version bump. Absolute timings are machine-specific (measured on Apple Silicon, --release); the percentage deltas are the portable signal.

In-process benches at a glance

The crate ships ten criterion benches that exercise pure-function hot paths across every feature without an HTTP harness. All are reproducible from a clean checkout:

cargo bench --bench cache                       # tile cache lookups
cargo bench --bench cog --features stac         # COG path classification (vsicurl/vsis3)
cargo bench --bench duckdb --features duckdb    # tile→bbox + SQL template substitution
cargo bench --bench frontend                    # MIME guessing + cache-control rule
cargo bench --bench geoparquet --features geoparquet # tile→bbox (plain + buffered)
cargo bench --bench mlt --features mlt          # MLT parse / decode / transcode
cargo bench --bench postgres --features postgres # geometry-type + JSON Schema mapping
cargo bench --bench raster --features raster    # raster encode / mosaic / band math
cargo bench --bench stac --features stac        # bbox merging + URL classification
cargo bench --bench styles                      # style.json URL rewriting

Why these ten: they cover the per-request work that runs inside the Tokio worker — between when the router dispatches and when bytes are written back to the socket. Network, Postgres round-trips, and disk reads are excluded by design (those are measured in the benchmarks/ HTTP harness). Every cargo feature flag now has at least one corresponding bench so contributors can verify their changes haven't regressed any feature's hot path.