Benchmarks Tile servers Matchup
Candidates
Martin vs go-pmtiles vs tileserver-gl vs mbtileserver vs BBOX
Tick the candidates you care about and the charts and tables redraw for just them: same run, same rig, same samples. The verdict stays on the published page.
Scorecard
| Candidate | Single-tile latency (ms, lower is better) | Throughput under concurrency (req/s, higher is better) | Random tile walk (req/s, higher is better) | Viewport burst (ms, lower is better) | Cold start and footprint (ms, lower is better) |
|---|---|---|---|---|---|
| Martin (PMTiles) | 1.2 ms | 12634.2 req/s | 12992.8 req/s | 1.2 ms | 158.4 ms |
| Martin (MBTiles) | 1.2 ms | 9185.5 req/s | 13724.0 req/s | 1.2 ms | 153.2 ms |
| go-pmtiles | 2.0 ms | 4513.5 req/s | 4533.1 req/s | 1.9 ms | 157.4 ms |
| tileserver-gl-light | 14.3 ms | 281.2 req/s | 793.2 req/s | 18.7 ms | 276.4 ms |
| mbtileserver | 1.1 ms | 7856.6 req/s | 7110.4 req/s | 2.2 ms | 137.2 ms |
| BBOX (PMTiles) | 2.3 ms | 3961.5 req/s | 8292.2 req/s | 1.9 ms | 117.6 ms |
| BBOX (MBTiles) | 2.7 ms | 2202.2 req/s | 6503.4 req/s | 2.6 ms | 149.8 ms |
Single-tile latency
One client, one tile at a time: mean response time for a dense street-level tile, a district tile, a regional tile and an overview tile, each read from the archive on every request.
Test 1 Street level z14
Mean latency over one connection. The densest kind of tile: a 1.5 km Prenzlauer Berg tile with every building, road, POI and landuse polygon of the OpenMapTiles schema. (LOWER ms = FASTER)
Test 2 District z12
Mean latency over one connection. Inner-city tile covering Prenzlauer Berg and Mitte: roads, landuse, water and place labels, buildings dropped. (LOWER ms = FASTER)
Test 3 Region z9
Mean latency over one connection. Low-zoom tile covering most of Berlin: boundaries, major roads, water, place names. (LOWER ms = FASTER)
Test 4 Overview z5
Mean latency over one connection. Small tile at the top of the pyramid: the extract's footprint in one tile, mostly boundaries and a few labels. (LOWER ms = FASTER)
Throughput under concurrency
Closed-loop load on the street level z14 tile at 10 and 100 concurrent connections; every request reads the same tile out of the archive.
Test 1 Throughput, 10 connections
Successful street level z14 tiles per second with 10 closed-loop clients. (HIGHER req/s = BETTER)
Test 2 Throughput, 100 connections
Successful street level z14 tiles per second with 100 closed-loop clients. (HIGHER req/s = BETTER)
Test 3 p99 latency, 10 connections
Median across passes of each pass's p99 response time at 10 connections. A server that completed no request in a pass is charged the whole 12 s pass. (LOWER ms = FASTER)
Test 4 p99 latency, 100 connections
Median across passes of each pass's p99 response time at 100 connections. A server that completed no request in a pass is charged the whole 12 s pass. (LOWER ms = FASTER)
Test 5 Failed requests, 100 connections
Non-2xx responses plus connection errors and 60 s timeouts as a share of all requests at 100 connections. (LOWER % = FASTER)
Random tile walk
10 clients each requesting a random z14 tile from a 256-tile block of central Berlin, so no server can win on one hot tile and every request goes through the archive's index.
Test 1 Throughput, 10 connections
Successful tiles per second when 10 closed-loop clients each request a random z14 tile from a 256-tile block of central Berlin. (HIGHER req/s = BETTER)
Test 2 p99 latency, 10 connections
Median across passes of each pass's p99 response time on the random walk. A server that completed no request in a pass is charged the whole 12 s pass. (LOWER ms = FASTER)
Viewport burst
Four adjacent z14 tiles requested at once, the way a map view loads; wall time until the last one lands.
Test 1 Viewport, mean
Mean wall time until all four z14 tiles have arrived, requested in parallel. (LOWER ms = FASTER)
Test 2 Viewport, slowest
The slowest of the measured viewport loads. (LOWER ms = FASTER)
Cold start and footprint
Time from process start to the first served tile, and resident memory idle and after load.
Test 1 Time to first tile
Mean time from container process start until the street-level tile is served, including opening the archive and reading its metadata, over five starts. (LOWER ms = FASTER)
Test 2 Idle memory
Container RSS five seconds after the first tile, before any load. (LOWER MB = FASTER)
Test 3 Memory after load
Container RSS right after the concurrency passes. (LOWER MB = FASTER)
Test conditions: rig, corpus, protocol
- Machine
- MacBook Pro
- Chip
- Apple M2 Max
- Cores
- 12 CPU cores (8 performance, 4 efficiency)
- Memory
- 96 GB
- OS
- macOS 26.6.2 (arm64)
- Runtime
- Docker 29.4.0 (OrbStack); one tile server container at a time (4 CPUs, 4 GB) with both archives bind-mounted read-only; oha 1.16.0 on the host
- Warmups
- 3 unmeasured passes
- Measured
- 20 passes per tool
- Process
- One server container at a time reading one of two archives with identical tiles (berlin.mbtiles from Planetiler 0.10.2, berlin.pmtiles converted from it by go-pmtiles); 4 workers where the concept exists; 5 cold starts, 20 single-tile passes of 20 requests per tile, 20 four-tile viewport bursts, 8 passes of 12 s at 10 and at 100 connections on one tile, 8 passes of 12 s at 10 connections over 256 random tiles
- Cache
- No server-side tile cache configured anywhere (go-pmtiles keeps its default 64 MB directory cache); archives in the page cache after warmups; every request accepts gzip, as map clients do, except the identity variant
- Output
- oha 1.16.0 JSON (latency percentiles, request counts, status codes); Bun fetch for cold start, parity and viewport bursts; docker stats for RSS
What this does not prove
- One laptop: the server under test (4 CPUs, 4 GB) and the load generator shared a 12-core M2 Max under OrbStack. Numbers are relative to each other, not absolute capacity.
- At the top of the table the harness is part of the measurement: 12,600 tiles a second of 242 KB is about 3 GB/s through the Docker port forward into oha on the same host. The gaps between Martin, mbtileserver and go-pmtiles are real in direction; their size at the top may be understated.
- Every request sends Accept-Encoding: gzip, as browsers and MapLibre do, so a server can pass the stored gzip bytes through and nobody is charged for the client's choice. What each server does for a client that sends no Accept-Encoding is recorded but not scored: Martin and BBOX honour the missing header and decompress the tile on every request (2.9 ms and 9 to 10 ms for the street-level tile instead of 1.2 and 2.3 to 2.8 ms), while go-pmtiles, mbtileserver and tileserver-gl send Content-Encoding: gzip regardless. Both are defensible choices for a tile server; ranking them would charge the first group for following the request. A first full run that sent no Accept-Encoding on every request did exactly that and was discarded.
- tileserver-gl is measured as tileserver-gl-light, a single Node.js process without MapLibre Native. It gunzips every stored tile and re-gzips it before sending (243,510 bytes out for a 242,407-byte stored tile), which is where its 12 ms go. Server-side raster rendering, the reason many people run it, is not measured here.
- go-pmtiles ran with its default 64 MB directory cache; it caches PMTiles directory entries, not tiles. go-pmtiles is designed to serve archives from object storage over HTTP range requests; a local file is the simplest case for it, not its common one.
- Martin's PMTiles source showed a p99 of about 203 ms in every one of its eight 100-client passes while its MBTiles source stayed at 14 ms, and its container held 168 MB after load against 63 MB for MBTiles. Both are reported, neither is explained by this run.
- The archive is 41 MB and sat entirely in the page cache after the warmups. This is hot-cache serving: server overhead, archive index lookup and delivery, not disk or object-storage behaviour. A planet-scale archive of 80 GB or more, where PMTiles directory lookups and the MBTiles SQLite index stop fitting in memory, is a different benchmark.
- Planetiler built the MBTiles with --compact_db=false because BBOX 0.6.2 rejects Planetiler's default compact schema (tiles_shallow plus tiles_data). The flat tiles table is the standard MBTiles layout; the compact one would have been smaller and may serve differently.
- Cold start is process start to first tile, so it includes opening the archive and reading its metadata; every server did it in 118 to 276 ms on average, and the spread between a server's five starts (BBOX PMTiles 61 to 135 ms) is of the same order as the gaps between servers below tileserver-gl.
- Memory is Docker's reported usage for the container (cgroup usage minus inactive file cache), sampled once idle and once after the random walk.
- BBOX sends no Content-Type header on tiles from its PMTiles source (it does from MBTiles); tileserver-gl's re-compressed tiles differ in size from the stored bytes. Feature counts and layers were identical for all seven candidates on all four test tiles.
- mbtileserver ran from the project's linux_arm64 v0.11.0 release binary, packaged on debian:bookworm-slim, because its published image is amd64-only. BBOX ran from a source build of v0.6.2 for the same reason.
- Single run on one day. No candidate failed a single request in any pass; every sample is in the run file.
Rerun it yourself: the runner and configuration are in the repository, and the methodology page covers what every run holds constant. Think a result is wrong? Open an issue with your rig and your samples.
Martin is a project of the MapLibre organization. go-pmtiles and the PMTiles format are developed by Protomaps. tileserver-gl is developed by MapTiler. mbtileserver is developed by the Conservation Biology Institute. BBOX is developed by Sourcepole. Planetiler is developed by Michael Barry and contributors; the tile schema is OpenMapTiles. Map data: OpenStreetMap contributors, ODbL, via the Geofabrik Berlin extract of 2026-01-01; water polygons from OSMData, Natural Earth public domain.