Documentation

Single-tile throughput numbers (req/s, p99) miss what users actually experience. When you pan or zoom a MapLibre GL JS map, the browser fires a grid of tile requests in parallel to fill the viewport. The metric that matters is time-to-full-viewport — how long until the last tile of the grid paints.

Grid mode simulates exactly this.

Quick start

# Bring up the benchmark stack (tileserver-rs + tileserver-gl + martin + titiler)
docker compose -f benchmarks/docker-compose.yml --profile all up -d

# Run grid mode — default is 4×4 = 16 tiles per viewport
pnpm --filter @tileserver-rs/benchmarks run bench --mode grid

# Match a 1920×1080 browser viewport exactly
pnpm --filter @tileserver-rs/benchmarks run bench --mode grid --grid-size 5x4

# More iterations for tighter p99 confidence intervals
pnpm --filter @tileserver-rs/benchmarks run bench --mode grid --iterations 100

How the grid is built

For a 1920×1080 viewport at 512 px tiles you need ⌈1920/512⌉ + 1 = 5 tiles wide and ⌈1080/512⌉ + 1 = 4 tiles tall — 20 tiles total. A 4×4 default captures a typical retina laptop viewport. The benchmark centers the grid on a well-known test location (Florence for PMTiles, Zurich for MBTiles, the Alps for STAC raster) and fires every tile request concurrently via Promise.all — identical to MapLibre's browser fetch pattern.

z=12 centered on Florence (2177, 1494), 4×4 grid:

  (2175, 1492)  (2176, 1492)  (2177, 1492)  (2178, 1492)
  (2175, 1493)  (2176, 1493)  (2177, 1493)  (2178, 1493)
  (2175, 1494)  (2176, 1494)  (2177, 1494)  (2178, 1494)
  (2175, 1495)  (2176, 1495)  (2177, 1495)  (2178, 1495)

What gets measured

Each grid-load iteration records:

MetricWhat it represents
Grid latencyTime from firing the first request to receiving the last response (≈ time-to-viewport-full)
Grids/secSustainable viewport loads per second (1000 / avg_grid_latency_ms)
ThroughputTotal bytes transferred per grid (all 16-20 tiles)
Max tile latencySlowest single tile in the grid — the one that blocks viewport paint

Every zoom level gets 3 warmup iterations (to prime caches, JIT, connection pools) before the measurement iterations kick in. This matters because cold-path STAC and COG tiles can take 30–60 s to hydrate, and warmup separates that from the steady-state number.

Results

Run captured 2026-10-16 on the same setup as the performance benchmarks (Apple Silicon, 12 cores, Docker, quiet host gate). 4×4 grid, 50 iterations per zoom after 3 warmup loads. Values are grids/s · average grid latency · P99.

SourceZoomtileserver-rsmartintileserver-gl
PMTiles (Florence)z14192 · 5 ms · 8 ms162 · 6 ms · 7 ms68 · 15 ms · 58 ms
PMTiles (Florence)z15127 · 8 ms · 10 ms110 · 9 ms · 11 ms38 · 26 ms · 66 ms
MBTiles (Zurich)z12143 · 7 ms · 8 ms151 · 7 ms · 8 ms69 · 14 ms · 17 ms
MBTiles (Zurich)z13145 · 7 ms · 9 ms151 · 7 ms · 10 ms73 · 14 ms · 21 ms
MBTiles (Zurich)z1452 · 19 ms · 22 ms52 · 19 ms · 21 ms18 · 57 ms · 68 ms
PostGIS tablez12see note162 · 6 ms · 9 ms—
PostGIS tablez1399 · 10 ms · 14 ms210 · 5 ms · 6 ms—
PostGIS tablez14245 · 4 ms · 5 ms425 · 2 ms · 5 ms—
PostGIS functionz1256 · 18 ms · 26 ms178 · 6 ms · 7 ms—
PostGIS functionz1394 · 11 ms · 14 ms227 · 4 ms · 6 ms—
PostGIS functionz14243 · 4 ms · 5 ms490 · 2 ms · 4 ms—

On PMTiles tileserver-rs fills a city-zoom viewport fastest (5 ms, P99 8 ms), about 3× faster than tileserver-gl. On MBTiles it ties martin. On PostGIS martin is about 2× faster, on tables and on function sources. That gap is open work.

Warning

PostGIS table, z12: 7 of the 16 grid tiles lie outside the source bounds. tileserver-rs answers those with 404, martin with an empty 2xx, so the harness logged 350 errors (7 tiles × 50 loads) for tileserver-rs. The row is left out because the two servers did different work.

Info

PostGIS function: these rows are from the re-run after the function-source cache fix. Before the fix the tile route sent every function-source request past the tile cache, and tileserver-rs managed only 9–12 grids/s.

Why this matters more than req/s

Single-tile req/s numbers can mislead. A server that answers one tile in 10 ms but serializes the next 15 behind it still takes 160 ms to fill a viewport — the user experiences 160 ms, not 10 ms. Grid mode measures the parallel reality.

It also exposes tail latency. A server with 50 ms average and 600 ms p99 will feel janky because every viewport load is only as fast as its slowest tile. maxTileLatency in the per-iteration output surfaces this directly.

CLI reference

-m, --mode <mode>         single (default) | grid
-g, --grid-size <WxH>     grid dimensions, e.g. 4x4 (default), 5x4, 3x3
-i, --iterations <num>    per-zoom iterations (default: 50)

Raster formats automatically scale iteration count down by 3× (raster tiles are 10-50× slower than vector) so a full run completes in reasonable time without sacrificing statistical significance.

Grid mode supports the same --type, --server, and --markdown flags as single-tile mode — they all compose.

When to use each mode

ModeUse when
single (default)Measuring raw server capacity — how many individual tiles per second under sustained load
gridMeasuring user-perceived map load time — how fast a viewport fills

Run both. Single-tile tells you whether your server can handle a load-test scenario (many clients, each fetching one tile). Grid tells you what a single user will experience sitting in front of the browser.