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.shmeasured 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'sstability.log. - Errors: 0 non-2xx responses in every case below.
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
| Source | File | Area | Zoom Range | Size |
|---|---|---|---|---|
| PMTiles | protomaps-sample.pmtiles | Florence, Italy | 0-15 | 6.3 MB |
| MBTiles | zurich_switzerland.mbtiles | Zurich, Switzerland | 0-14 | 34 MB |
| PostgreSQL | benchmark_points table | Zurich, Switzerland | 0-14 | 50,000 points |
| COG | benchmark-rgb.cog.tif | World (Web Mercator) | 0-3 | 18 MB |
| DEM | test-dem.cog.tif | San Francisco Bay | 9-12 | 0.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:
| Group | tileserver-rs | martin | tileserver-gl | titiler | Result |
|---|---|---|---|---|---|
| PMTiles (vector) | 1,336 req/s · 83 ms | 1,615 req/s · 71 ms | 1,280 req/s · 90 ms | — | martin ahead — but see encoding |
| MBTiles (vector) | 752 req/s · 190 ms | 856 req/s · 161 ms | 692 req/s · 208 ms | — | martin ahead — but see encoding |
| Same tile, same encoding | 1,475 req/s (br) | 1,477 req/s (br) | gzip only | — | tie |
| PostGIS table | 1,339 req/s · 514 ms | 1,244 req/s · 505 ms | — | — | tileserver-rs +8 % |
| PostGIS function | 1,447 req/s · 184 ms | 1,502 req/s · 167 ms | — | — | martin +4 % |
| COG (raster) | 337 req/s · 293 ms | — | — | 96 req/s · 1,016 ms | tileserver-rs 3.5× |
| DEM terrain-RGB | 276 req/s · 410 ms | — | — | 146 req/s · 743 ms | tileserver-rs 1.9× |
| Raster style render | 152 req/s · 68 ms | — | 204 req/s · 49 ms | — | tileserver-gl +34 % |
| OGC API Features | 11,342 req/s · 11 ms | — | — | — | no competitor |
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:
| Encoding | tileserver-rs | martin | tileserver-gl |
|---|---|---|---|
| identity (82.93 KB) | 1,867 req/s · 52.9 ms | 1,866 req/s · 52.8 ms | sends gzip anyway |
| br (60.5 KB) | 1,475 req/s · 70.3 ms | 1,477 req/s · 68.5 ms | sends gzip anyway |
| zstd (64.6 KB) | 1,386 req/s · 73.5 ms | 1,395 req/s · 72.0 ms | sends gzip anyway |
| gzip (61.8 KB) | 1,388 req/s · 73.3 ms | 1,462 req/s · 70.4 ms | 1,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)
| Zoom | tileserver-rs | martin | tileserver-gl |
|---|---|---|---|
| z10 | 1,288 · 82 ms · p99 162 ms | 1,618 · 61 ms · p99 394 ms | 1,218 · 84 ms · p99 145 ms |
| z11 | 1,443 · 71 ms · p99 138 ms | 1,818 · 54 ms · p99 342 ms | 1,457 · 70 ms · p99 123 ms |
| z12 | 1,551 · 66 ms · p99 128 ms | 1,900 · 52 ms · p99 311 ms | 1,539 · 66 ms · p99 117 ms |
| z13 | 1,440 · 72 ms · p99 142 ms | 1,885 · 52 ms · p99 321 ms | 1,439 · 71 ms · p99 127 ms |
| z14 | 1,580 · 66 ms · p99 106 ms | 1,816 · 54 ms · p99 320 ms | 1,485 · 68 ms · p99 114 ms |
| z15 | 711 · 142 ms · p99 259 ms | 651 · 151 ms · p99 583 ms | 541 · 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)
| Zoom | tileserver-rs | martin | tileserver-gl |
|---|---|---|---|
| z10 | 591 · 184 ms · p99 328 ms | 686 · 144 ms · p99 794 ms | 570 · 186 ms · p99 320 ms |
| z11 | 890 · 118 ms · p99 202 ms | 1,086 · 91 ms · p99 360 ms | 828 · 120 ms · p99 182 ms |
| z12 | 1,058 · 100 ms · p99 195 ms | 1,134 · 87 ms · p99 357 ms | 959 · 103 ms · p99 142 ms |
| z13 | 989 · 109 ms · p99 209 ms | 1,130 · 88 ms · p99 356 ms | 915 · 110 ms · p99 183 ms |
| z14 | 233 · 440 ms · p99 699 ms | 245 · 395 ms · p99 1,233 ms | 189 · 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.
| Zoom | tileserver-rs | martin |
|---|---|---|
| z10 | 66 · 1,369 ms · p99 6,903 ms | 71 · 1,296 ms · p99 3,052 ms |
| z11 | 119 · 794 ms · p99 4,190 ms | 119 · 801 ms · p99 2,311 ms |
| z12 | 312 · 313 ms · p99 1,282 ms | 297 · 327 ms · p99 1,058 ms |
| z13 | 1,286 · 77 ms · p99 323 ms | 1,237 · 80 ms · p99 404 ms |
| z14 | 4,914 · 20 ms · p99 23 ms | 4,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.
| Zoom | tileserver-rs | martin | tileserver-rs before fix |
|---|---|---|---|
| z10 | 364 · 269 ms · p99 1,536 ms | 407 · 241 ms · p99 804 ms | 179 · 541 ms |
| z11 | 381 · 256 ms · p99 1,323 ms | 396 · 247 ms · p99 770 ms | 159 · 608 ms |
| z12 | 339 · 289 ms · p99 1,310 ms | 391 · 251 ms · p99 780 ms | 147 · 654 ms |
| z13 | 1,159 · 85 ms · p99 559 ms | 1,297 · 76 ms · p99 327 ms | 191 · 511 ms |
| z14 | 4,991 · 20 ms · p99 23 ms | 5,021 · 19 ms · p99 23 ms | 215 · 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)
| Zoom | tileserver-rs | titiler |
|---|---|---|
| z0 | 347 · 286 ms | 119 · 780 ms |
| z1 | 345–370 · 267–285 ms | 95–98 · 957–986 ms |
| z2 | 324–329 · 299–304 ms | 66–78 · 1,200–1,395 ms |
| z3 | 308 · 319 ms | 122 · 776 ms |
DEM terrain-RGB (Terrarium)
| Zoom | tileserver-rs | titiler (algorithm=terrarium) |
|---|---|---|
| z9 | 402 · 245 ms | 225 · 436 ms |
| z10 | 238 · 411 ms | 169 · 568 ms |
| z11 | 143 · 674 ms | 90 · 1,059 ms |
| z12 | 318 · 309 ms | 101 · 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)
| Zoom | tileserver-rs | tileserver-gl |
|---|---|---|
| z10 | 187 · 53 ms | 205 · 48 ms |
| z11 | 197 · 50 ms | 230 · 43 ms |
| z12 | 148 · 67 ms | 214 · 46 ms |
| z13 | 112 · 88 ms | 201 · 49 ms |
| z14 | 119 · 84 ms | 172 · 58 ms |
Both use MapLibre Native. tileserver-gl is faster from z12 up.
OGC API Features
| Case | tileserver-rs |
|---|---|
| single feature | 23,082 req/s · 3.8 ms |
| countries bbox | 14,362 req/s · 6.5 ms |
| cities CQL2 filter | 7,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
| Feature | tileserver-rs | tileserver-gl | martin | titiler |
|---|---|---|---|---|
| Language | Rust | Node.js | Rust | Python |
| 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
- Use release builds -
cargo build --releaseis 10-50x faster than debug - Use PMTiles for production - cloud-native, HTTP range request friendly
- Use MBTiles for development - easy to generate and inspect with SQLite tools
- Enable compression - gzipped tiles reduce bandwidth significantly
- Use CDN caching - tiles are immutable, cache with long TTLs
- Match connections to cores - more connections than CPU cores adds overhead
- 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
| Benchmark | Median time | What it measures |
|---|---|---|
raster_encode_256/png | 216 µs | RGBA → PNG encode at the HTTP boundary. |
raster_encode_256/jpeg_q85 | 715 µs | RGBA → JPEG q=85. |
raster_encode_256/webp_lossless | 280 µs | RGBA → lossless WebP. |
raster_decode_png_256 | 131 µs | PNG → RasterImage (band-math ingest path). |
raster_mosaic_5_assets_256/first | 115 µs | 5-layer mosaic, first method. Short-circuits once canvas is opaque, so it's 5-20× faster than stats methods. |
raster_mosaic_5_assets_256/highest | 636 µs | Per-pixel max over 5 layers. |
raster_mosaic_5_assets_256/lowest | 644 µs | Per-pixel min over 5 layers. |
raster_mosaic_5_assets_256/mean | 1.38 ms | Per-pixel arithmetic mean. |
raster_mosaic_5_assets_256/median | 2.58 ms | Per-pixel median (retains all layers, sorts per-pixel). Heaviest; use only when outlier robustness matters. |
raster_mosaic_5_assets_256/stdev | 1.49 ms | Per-pixel standard deviation. |
raster_mosaic_5_assets_256/count | 214 µs | Per-pixel count of valid contributions. |
raster_band_math_ndvi_256 | 1.52 ms | Pure-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.rsin 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 savedper 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
| Benchmark | Median time | Style fixture |
|---|---|---|
style_rewrite/tiny_no_key | 789 ns | 1 source, 0 layers — synthetic minimal style |
style_rewrite/tiny_with_key | 1.04 µs | Adds ?key=… rewriting cost to every URL |
style_rewrite/protomaps_light_no_key | 7.37 µs | Real-world Protomaps Light (4 sources, 80 layers) |
style_rewrite/protomaps_light_with_key | 7.78 µs | Same 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
| Zoom | Bridge (before) | Native (after) | Change | Throughput |
|---|---|---|---|---|
| z0 | 15.59 ms | 16.67 ms | +6.95% | 5.06 → 4.73 MiB/s |
| z4 | 184.6 ms | 142.3 ms | −22.9% | 9.51 → 12.34 MiB/s |
| z7 | 230.2 ms | 183.2 ms | −20.4% | 7.65 → 9.61 MiB/s |
| z13 | 19.72 ms | 15.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.