Documentation

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

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-2290 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.

SourceAvg Requests/secAvg ThroughputAvg Latency
PMTiles1,355 req/s89.39 MB/s81ms
MBTiles759 req/s90.71 MB/s182ms
PostGIS3,571 req/s429.06 MB/s187ms
PostGIS @ z1412,701 req/s411.15 MB/s7.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)

ZoomLocationRequests/secThroughputAvg LatencyP99 Latency
z10Florence1,27889.03 MB/s80.55ms155ms
z11Florence1,51789.25 MB/s67.00ms73ms
z12Florence1,58488.56 MB/s63.28ms102ms
z13Florence1,45788.41 MB/s70.83ms137ms
z14Florence1,49684.41 MB/s67.91ms125ms
z15Florence79696.69 MB/s136.66ms167ms

MBTiles (Zurich, Switzerland)

ZoomLocationRequests/secThroughputAvg LatencyP99 Latency
z10Zurich58791.8 MB/s179.47ms243ms
z11Zurich88788.87 MB/s113.43ms220ms
z12Zurich1,08791.44 MB/s94.72ms183ms
z13Zurich99392.09 MB/s103.99ms203ms
z14Zurich23989.35 MB/s420.84ms488ms

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_hit micro-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

AspectPMTilesMBTiles
Best forProduction, CDN, cloudDevelopment, local
ConsistencyMore predictableVariable by tile size
High-zoom perfExcellentDrops sharply at z14+
Low-zoom perfGoodGood

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)

ServerAvg Req/secAvg LatencyThroughput
tileserver-rs1,38479.26ms91.04 MB/s
tileserver-gl1,19993.62ms77.39 MB/s
martin661,541.89ms6.97 MB/s

tileserver-rs is ~15% faster than tileserver-gl and ~21× faster than martin for PMTiles serving.

MBTiles Performance (Zurich, Switzerland)

ServerAvg Req/secAvg LatencyThroughput
tileserver-rs761183.45ms91.08 MB/s
tileserver-gl709210.58ms83.30 MB/s
martin708154.83ms154.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.

ServerAvg Req/secAvg LatencyThroughput
tileserver-rs3,571186.64ms429.06 MB/s
martin3,439200.62ms436.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)

Zoomtileserver-rsmartinWinner
z10204 / 493ms212 / 524ms~tie
z11357 / 291ms337 / 318mstileserver-rs
z12896 / 115ms882 / 124ms~tie
z133,699 / 27ms3,500 / 28mstileserver-rs
z1412,701 / 7.4ms12,265 / 7.6mstileserver-rs
Success

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

Featuretileserver-rstileserver-glmartin
LanguageRustNode.jsRust
PMTiles
MBTiles
MLT (MapLibre Tiles)
PostGIS
Raster Rendering✅ Native✅ Node
Static Images
PMTiles Req/sec1,3551,27453
MBTiles Req/sec759756876
PostGIS Req/sec3,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)

ZoomRequests/secThroughputAvg LatencyP99 Latency
z02 req/s276 KB/s2,568ms4,343ms
z17 req/s1 MB/s1,349ms2,261ms
z220 req/s3.4 MB/s478ms1,177ms
z338 req/s7.6 MB/s258ms595ms

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.

ServerAvg Req/secAvg LatencyMemory UsageNotes
TiTiler184 req/s54ms200 MBRio-tiler/GDAL, Python (FastAPI)
tileserver-rs19 req/s1,217ms624 MBGDAL-based, Rust

COG Performance by Zoom Level

Zoomtileserver-rsTiTilerWinner
z03 req/s (3,016ms)157 req/s (63ms)TiTiler
z15-8 req/s (1,174-2,054ms)170-194 req/s (51-58ms)TiTiler
z222-23 req/s (419-453ms)196-199 req/s (50ms)TiTiler
z353 req/s (189ms)187 req/s (53ms)TiTiler
Info

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:

Capabilitytileserver-rsTiTiler
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
Success

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 -d to start both servers for comparison.

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.

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).

ServerAvg Req/secAvg ThroughputAvg LatencyErrors
tileserver-rs v2.25.1+12262.57 MB/s57 ms0
titiler (latest)4047 KB/s1205 ms0

tileserver-rs serves COG tiles ~30× more throughput at ~21× lower latency than titiler on the same hardware.

Per-zoom detail:

Zoomtileserver-rs req/stileserver-rs latencytitiler req/stitiler latencySpeedup
z0 (world)305163 ms54816 ms5.6×
z1 cold101149 ms281634 ms36×
z1 warm156231 ms351293 ms45×
z2 cold144534 ms361299 ms40×
z2 warm149033 ms52913 ms29×
z3154232 ms381274 ms41×

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.rs in 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
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).

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 saved per tile).