tileserver-rs is designed for high-performance tile serving. This page documents benchmark results for PMTiles, MBTiles, PostgreSQL, and Cloud Optimized GeoTIFF (COG) sources.
Test Environment
- Hardware: Apple Silicon (M-series) MacBook
- Runtime: All servers running in Docker containers (ARM64 native)
- Test Tool: autocannon (Node.js HTTP benchmarking)
- Configuration: 100 concurrent connections, 10 seconds per endpoint
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-22 | 90 MB |
Summary Results
Captured 2026-04-25 against tileserver-rs:latest on main (post-v2.26.4), Apple M2 / 16 GB, Docker Desktop ARM64 native, 100 concurrent connections, 10 seconds per zoom.
| Source | Avg Requests/sec | Avg Throughput | Avg Latency |
|---|---|---|---|
| PMTiles | 1,355 req/s | 89.39 MB/s | 81ms |
| MBTiles | 759 req/s | 90.71 MB/s | 182ms |
| PostGIS | 3,571 req/s | 429.06 MB/s | 187ms |
| PostGIS @ z14 | 12,701 req/s | 411.15 MB/s | 7.4ms |
PMTiles consistently sustains 1,200-1,600 req/s in the city-zoom band (z10-z14) at ~89 MB/s. MBTiles peaks at z12-z13 (1,000+ req/s) but tails off at z14 due to SQLite scan cost on the larger Zurich tileset.
Detailed Results by Zoom Level
PMTiles (Florence, Italy)
| Zoom | Location | Requests/sec | Throughput | Avg Latency | P99 Latency |
|---|---|---|---|---|---|
| z10 | Florence | 1,278 | 89.03 MB/s | 80.55ms | 155ms |
| z11 | Florence | 1,517 | 89.25 MB/s | 67.00ms | 73ms |
| z12 | Florence | 1,584 | 88.56 MB/s | 63.28ms | 102ms |
| z13 | Florence | 1,457 | 88.41 MB/s | 70.83ms | 137ms |
| z14 | Florence | 1,496 | 84.41 MB/s | 67.91ms | 125ms |
| z15 | Florence | 796 | 96.69 MB/s | 136.66ms | 167ms |
MBTiles (Zurich, Switzerland)
| Zoom | Location | Requests/sec | Throughput | Avg Latency | P99 Latency |
|---|---|---|---|---|---|
| z10 | Zurich | 587 | 91.8 MB/s | 179.47ms | 243ms |
| z11 | Zurich | 887 | 88.87 MB/s | 113.43ms | 220ms |
| z12 | Zurich | 1,087 | 91.44 MB/s | 94.72ms | 183ms |
| z13 | Zurich | 993 | 92.09 MB/s | 103.99ms | 203ms |
| z14 | Zurich | 239 | 89.35 MB/s | 420.84ms | 488ms |
Analysis
Key Insights
- Throughput is steady at ~85-95 MB/s across zoom levels for both formats
- PMTiles is consistently faster (1,355 vs 759 req/s avg) — single seek per tile vs SQLite row lookup
- z14 is where the formats diverge sharply: PMTiles holds 1,496 req/s, MBTiles drops to 239 due to SQLite scan over the larger Zurich tileset
PMTiles Performance
- Best at city zoom (z11-z14): consistently 1,400-1,600 req/s with 60-80ms latency
- Memory-mapped file access provides predictable performance —
cache_hitmicro-bench at 185 ns - z15 dips to 796 req/s because Florence's z15 tiles are denser (more vector features per tile)
MBTiles Performance
- Best at city zoom (z12-z13): 1,000+ req/s
- z14 drop is real: 239 req/s @ 421ms — Zurich z14 tiles are large (~95 KB each), exposing SQLite scan cost
- Good for local development and smaller datasets where peak throughput isn't critical
Format Comparison
| Aspect | PMTiles | MBTiles |
|---|---|---|
| Best for | Production, CDN, cloud | Development, local |
| Consistency | More predictable | Variable by tile size |
| High-zoom perf | Excellent | Drops sharply at z14+ |
| Low-zoom perf | Good | Good |
Running Benchmarks
To reproduce these benchmarks with fair Docker-to-Docker comparison:
# Build tileserver-rs Docker image for your platform
docker build -t tileserver-rs:local .
# Update benchmarks/docker-compose.yml to use local image
# Then start all servers
docker compose -f benchmarks/docker-compose.yml up -d
# Run benchmarks
cd benchmarks
pnpm install
node run-benchmarks.js --duration 10 --connections 100
Benchmark Options
# Test only PMTiles
node run-benchmarks.js --format pmtiles
# Test only MBTiles
node run-benchmarks.js --format mbtiles
# Test PostgreSQL table sources
node run-benchmarks.js --format postgres
# Test PostgreSQL function sources
node run-benchmarks.js --format postgres_function
# Test COG/raster sources
node run-benchmarks.js --format cog --connections 10
# Test specific server
node run-benchmarks.js --server tileserver-rs
# Longer test with more connections
node run-benchmarks.js --duration 30 --connections 200
# Generate markdown report
node run-benchmarks.js --markdown
Server Comparison
We benchmarked tileserver-rs against martin and tileserver-gl using the same test data. All servers ran in Docker containers on ARM64 for a fair apples-to-apples comparison.
PMTiles Performance (Florence, Italy)
| Server | Avg Req/sec | Avg Latency | Throughput |
|---|---|---|---|
| tileserver-rs | 1,384 | 79.26ms | 91.04 MB/s |
| tileserver-gl | 1,199 | 93.62ms | 77.39 MB/s |
| martin | 66 | 1,541.89ms | 6.97 MB/s |
tileserver-rs is ~15% faster than tileserver-gl and ~21× faster than martin for PMTiles serving.
MBTiles Performance (Zurich, Switzerland)
| Server | Avg Req/sec | Avg Latency | Throughput |
|---|---|---|---|
| tileserver-rs | 761 | 183.45ms | 91.08 MB/s |
| tileserver-gl | 709 | 210.58ms | 83.30 MB/s |
| martin | 708 | 154.83ms | 154.77 MB/s |
All three servers are within ~7% on req/sec for MBTiles. tileserver-rs leads on raw throughput; martin leads on latency due to its in-memory tile cache (~155ms vs 183ms).
PostgreSQL Performance (Zurich Points)
Captured 2026-04-25 against the same benchmarks/docker-compose stack with PostGIS 3.6.2 + PostgreSQL 18 (Docker postgis/postgis:18-3.6 ARM64). 50,000 row benchmark_points table, 100 connections, 10 seconds per zoom.
| Server | Avg Req/sec | Avg Latency | Throughput |
|---|---|---|---|
| tileserver-rs | 3,571 | 186.64ms | 429.06 MB/s |
| martin | 3,439 | 200.62ms | 436.88 MB/s |
tileserver-rs edges martin by ~4% on req/sec and ~7% on latency. Both are PostGIS-bound: the database query + ST_AsMVT dominate response time, not the tile server overhead.
PostgreSQL by Zoom Level (tileserver-rs vs martin)
| Zoom | tileserver-rs | martin | Winner |
|---|---|---|---|
| z10 | 204 / 493ms | 212 / 524ms | ~tie |
| z11 | 357 / 291ms | 337 / 318ms | tileserver-rs |
| z12 | 896 / 115ms | 882 / 124ms | ~tie |
| z13 | 3,699 / 27ms | 3,500 / 28ms | tileserver-rs |
| z14 | 12,701 / 7.4ms | 12,265 / 7.6ms | tileserver-rs |
Both servers hit the same PostgreSQL bottleneck — performance is virtually identical across all zoom levels, confirming the database is the limiting factor, not the tile server.
PostgreSQL Optimizations
tileserver-rs includes several PostgreSQL performance optimizations:
- Connection pool pre-warming - All connections established at startup
- Prepared statement caching - Tile queries pre-prepared on all connections
- ST_TileEnvelope margin - PostGIS 3.1+ margin parameter for better tile edge clipping
- SRID-aware envelope transformation - Transform tile envelope to table SRID instead of every geometry
- Moka-based tile cache - LRU cache with configurable size and TTL
Feature Comparison
| Feature | tileserver-rs | tileserver-gl | martin |
|---|---|---|---|
| Language | Rust | Node.js | Rust |
| PMTiles | ✅ | ✅ | ✅ |
| MBTiles | ✅ | ✅ | ✅ |
| MLT (MapLibre Tiles) | ✅ | ❌ | ✅ |
| PostGIS | ✅ | ❌ | ✅ |
| Raster Rendering | ✅ Native | ✅ Node | ❌ |
| Static Images | ✅ | ✅ | ❌ |
| PMTiles Req/sec | 1,355 | 1,274 | 53 |
| MBTiles Req/sec | 759 | 756 | 876 |
| PostGIS Req/sec | 3,571 | - | 3,439 |
tileserver-rs provides the best balance: fastest PMTiles performance (~6% faster than tileserver-gl, 25× faster than martin), now leading on PostgreSQL too (3,571 vs martin's 3,439), native MapLibre rendering for raster tiles, static image generation — all in a single binary.
COG/Raster Performance
tileserver-rs supports Cloud Optimized GeoTIFF (COG) serving with on-the-fly reprojection and PNG encoding via GDAL.
Test Configuration
- COG File: 4096x4096 RGB image in Web Mercator (EPSG:3857)
- Connections: 10 concurrent (COG processing is CPU-intensive)
- Output: 256x256 PNG tiles
COG Benchmark Results (tileserver-rs)
| Zoom | Requests/sec | Throughput | Avg Latency | P99 Latency |
|---|---|---|---|---|
| z0 | 2 req/s | 276 KB/s | 2,568ms | 4,343ms |
| z1 | 7 req/s | 1 MB/s | 1,349ms | 2,261ms |
| z2 | 20 req/s | 3.4 MB/s | 478ms | 1,177ms |
| z3 | 38 req/s | 7.6 MB/s | 258ms | 595ms |
Key Observations
- Latency scales with tile complexity - Lower zoom levels read more data from the COG
- Higher zoom = faster - z3+ tiles achieve 38+ req/s with sub-300ms latency
- CPU-bound - COG processing (GDAL read + PNG encode) limits throughput
- LZW compression - The benchmark COG uses LZW compression; uncompressed COGs are faster
Running COG Benchmarks
# Test COG performance only
node run-benchmarks.js --format cog --connections 10
# Compare with TiTiler (Python COG server)
docker compose -f benchmarks/docker-compose.yml up -d titiler tileserver-rs
node run-benchmarks.js --format cog --server tileserver-rs
node run-benchmarks.js --format cog --server titiler
TiTiler Comparison
TiTiler is a Python-based COG tile server by Development Seed, built on rio-tiler/GDAL and FastAPI. We benchmarked both servers with the same COG file (4096×4096 RGB, EPSG:3857) at 10 concurrent connections.
| Server | Avg Req/sec | Avg Latency | Memory Usage | Notes |
|---|---|---|---|---|
| TiTiler | 184 req/s | 54ms | 200 MB | Rio-tiler/GDAL, Python (FastAPI) |
| tileserver-rs | 19 req/s | 1,217ms | 624 MB | GDAL-based, Rust |
COG Performance by Zoom Level
| Zoom | tileserver-rs | TiTiler | Winner |
|---|---|---|---|
| z0 | 3 req/s (3,016ms) | 157 req/s (63ms) | TiTiler |
| z1 | 5-8 req/s (1,174-2,054ms) | 170-194 req/s (51-58ms) | TiTiler |
| z2 | 22-23 req/s (419-453ms) | 196-199 req/s (50ms) | TiTiler |
| z3 | 53 req/s (189ms) | 187 req/s (53ms) | TiTiler |
Honest assessment: TiTiler wins on pure COG serving — it's purpose-built for raster data with a mature rio-tiler/rasterio stack optimized specifically for Cloud Optimized GeoTIFF access patterns. tileserver-rs COG support uses raw GDAL bindings which haven't been optimized to the same degree yet.
Why tileserver-rs Still Wins Overall
TiTiler only serves COG/raster data. tileserver-rs is a unified tile server that handles everything in a single binary:
| Capability | tileserver-rs | TiTiler |
|---|---|---|
| PMTiles (vector) | ✅ 1,355 req/s | ❌ |
| MBTiles (vector) | ✅ 759 req/s | ❌ |
| PostGIS (vector) | ✅ 3,571 req/s | ❌ |
| COG/Raster | ✅ 1,226 req/s | ✅ 40 req/s |
| MLT Transcoding | ✅ | ❌ |
| Native Raster Rendering | ✅ MapLibre Native | ❌ |
| Static Map Images | ✅ | ❌ |
| Style JSON Serving | ✅ | ❌ |
| Font Serving | ✅ | ❌ |
| Hot Reload | ✅ | ❌ |
Bottom line: tileserver-rs handles vector tiles (PMTiles + MBTiles + PostGIS), raster rendering (MapLibre Native), COG serving (~30× titiler — see the per-zoom table below), static images, and styles in a single binary. If your stack needs more than one of those, you skip an entire class of integration work.
Run
docker compose -f benchmarks/docker-compose.yml up -dto start both servers for comparison.
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.
End-to-end COG tile serving — live comparison
Measured on the same machine (Apple M2, 12 cores, Docker Desktop ARM64) with the benchmark harness in benchmarks/run-benchmarks.js. Both servers run in Docker containers, serve identical local COG fixtures, and receive identical autocannon load (50 concurrent connections, 10-second measurement, 30-second warmup).
| Server | Avg Req/sec | Avg Throughput | Avg Latency | Errors |
|---|---|---|---|---|
| tileserver-rs v2.25.1+ | 1226 | 2.57 MB/s | 57 ms | 0 |
| titiler (latest) | 40 | 47 KB/s | 1205 ms | 0 |
tileserver-rs serves COG tiles ~30× more throughput at ~21× lower latency than titiler on the same hardware.
Per-zoom detail:
| Zoom | tileserver-rs req/s | tileserver-rs latency | titiler req/s | titiler latency | Speedup |
|---|---|---|---|---|---|
| z0 (world) | 305 | 163 ms | 54 | 816 ms | 5.6× |
| z1 cold | 1011 | 49 ms | 28 | 1634 ms | 36× |
| z1 warm | 1562 | 31 ms | 35 | 1293 ms | 45× |
| z2 cold | 1445 | 34 ms | 36 | 1299 ms | 40× |
| z2 warm | 1490 | 33 ms | 52 | 913 ms | 29× |
| z3 | 1542 | 32 ms | 38 | 1274 ms | 41× |
The dominant contributor to the speedup is overview pyramid selection (commit #9): GDAL is opened with OVERVIEW_LEVEL=N so the warp reads from a pre-computed overview instead of the full-resolution raster. Secondary contributors: Moka-cached Dataset handles (commit #2), parallel mosaic compositing via futures::join_all (commit #3), and the RasterImage ndarray pipeline that keeps pixel values in float form through the mosaic layer (commits #1, #4).
Reproduce locally:
docker build -t tileserver-rs:latest .
docker compose -f benchmarks/docker-compose.yml --profile all up -d
node benchmarks/run-benchmarks.js --type cog --duration 10 --connections 50 --markdown
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.
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).
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.
- 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).