Benchmarks Tile servers

Martin vs Tegola vs BBOX vs pg_tileserv vs TiPg vs ldproxy

Six open-source vector tile servers generating Mapbox Vector Tiles on the fly from the same PostGIS database, on a pinned Berlin OpenStreetMap corpus: single-tile latency, throughput at 10 and 100 clients, four-tile viewport bursts, cold start and memory. A re-run of the 2024 Rechsteiner thesis matrix on current releases.

Single-tile latency

One client, one tile at a time: mean response time for a dense building tile, a road tile, a point tile and a low-zoom boundary tile, each generated from PostGIS on every request.

Test 1 Buildings z14

Mean latency over one connection. Dense polygon tile: every building footprint in a 1.5 km Prenzlauer Berg tile (about 2,000 polygons). (LOWER ms = FASTER)

Buildings z14 (ms)
Martin13.6 ms
Tegola15.6 ms
BBOX15.2 ms
pg_tileserv13.7 ms
TiPg13.8 ms
ldproxy120.8 ms
ldproxy (PGIS_TILES)18.9 ms
Buildings z14 in ms
Martin13.6 ms
Tegola15.6 ms
BBOX15.2 ms
pg_tileserv13.7 ms
TiPg13.8 ms
ldproxy120.8 ms
ldproxy (PGIS_TILES)18.9 ms

Test 2 Roads z14

Mean latency over one connection. Line tile: every highway=* way in the same Prenzlauer Berg tile (about 2,500 lines). (LOWER ms = FASTER)

Roads z14 (ms)
Martin7.2 ms
Tegola16.5 ms
BBOX9.6 ms
pg_tileserv12.9 ms
TiPg9.2 ms
ldproxy83.6 ms
ldproxy (PGIS_TILES)15.1 ms
Roads z14 in ms
Martin7.2 ms
Tegola16.5 ms
BBOX9.6 ms
pg_tileserv12.9 ms
TiPg9.2 ms
ldproxy83.6 ms
ldproxy (PGIS_TILES)15.1 ms

Test 3 Points z14

Mean latency over one connection. Point tile: amenity, shop and tourism nodes in the same Prenzlauer Berg tile (about 2,800 points). (LOWER ms = FASTER)

Points z14 (ms)
Martin7.7 ms
Tegola17.4 ms
BBOX10.2 ms
pg_tileserv13.9 ms
TiPg8.8 ms
ldproxy89.4 ms
ldproxy (PGIS_TILES)16.2 ms
Points z14 in ms
Martin7.7 ms
Tegola17.4 ms
BBOX10.2 ms
pg_tileserv13.9 ms
TiPg8.8 ms
ldproxy89.4 ms
ldproxy (PGIS_TILES)16.2 ms

Test 4 Boundaries z9

Mean latency over one connection. Low-zoom polygon tile: the 55 administrative boundary polygons covering most of Berlin, city outline included. (LOWER ms = FASTER)

Boundaries z9 (ms)
Martin8.9 ms
Tegola10.8 ms
BBOX10.3 ms
pg_tileserv9.2 ms
TiPg10.4 ms
ldproxy25.4 ms
ldproxy (PGIS_TILES)9.3 ms
Boundaries z9 in ms
Martin8.9 ms
Tegola10.8 ms
BBOX10.3 ms
pg_tileserv9.2 ms
TiPg10.4 ms
ldproxy25.4 ms
ldproxy (PGIS_TILES)9.3 ms

Test 5 Buildings z14, gzip

Mean latency over one connection when the client accepts gzip, as browsers do: the buildings z14 tile including whatever compression the server applies. (LOWER ms = FASTER)

Buildings z14, gzip (ms)
Martin15.4 ms
Tegola14.9 ms
BBOX16.9 ms
pg_tileserv17.2 ms
TiPg17.3 ms
ldproxy119.4 ms
ldproxy (PGIS_TILES)19.2 ms
Buildings z14, gzip in ms
Martin15.4 ms
Tegola14.9 ms
BBOX16.9 ms
pg_tileserv17.2 ms
TiPg17.3 ms
ldproxy119.4 ms
ldproxy (PGIS_TILES)19.2 ms

Martin returned the buildings z14 tile in 13.6 ms on average; ldproxy took 121 ms (8.9x)

Throughput under concurrency

Closed-loop load on the buildings z14 tile at 10 and 100 concurrent connections; every request regenerates the tile from PostGIS through an 8-connection pool.

Test 1 Throughput, 10 connections

Successful buildings z14 tiles per second with 10 closed-loop clients. (HIGHER req/s = BETTER)

Throughput, 10 connections (req/s)
Martin485.2 req/s
Tegola264.7 req/s
BBOX494.3 req/s
pg_tileserv220.7 req/s
TiPg332.3 req/s
ldproxy33.0 req/s
ldproxy (PGIS_TILES)248.6 req/s
Throughput, 10 connections in req/s
Martin485.2 req/s
Tegola264.7 req/s
BBOX494.3 req/s
pg_tileserv220.7 req/s
TiPg332.3 req/s
ldproxy33.0 req/s
ldproxy (PGIS_TILES)248.6 req/s

Test 2 Throughput, 100 connections

Successful buildings z14 tiles per second with 100 closed-loop clients. (HIGHER req/s = BETTER)

Throughput, 100 connections (req/s)
Martin467.4 req/s
Tegola254.6 req/s
BBOX394.6 req/s
pg_tileserv169.3 req/s
TiPg456.2 req/s
ldproxy24.6 req/s
ldproxy (PGIS_TILES)226.0 req/s
Throughput, 100 connections in req/s
Martin467.4 req/s
Tegola254.6 req/s
BBOX394.6 req/s
pg_tileserv169.3 req/s
TiPg456.2 req/s
ldproxy24.6 req/s
ldproxy (PGIS_TILES)226.0 req/s

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)

p99 latency, 10 connections (ms)
Martin44.2 ms
Tegola70.1 ms
BBOX30.8 ms
pg_tileserv83.7 ms
TiPg47.4 ms
ldproxy1.067 s
ldproxy (PGIS_TILES)79.3 ms
p99 latency, 10 connections in ms
Martin44.2 ms
Tegola70.1 ms
BBOX30.8 ms
pg_tileserv83.7 ms
TiPg47.4 ms
ldproxy1.067 s
ldproxy (PGIS_TILES)79.3 ms

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)

p99 latency, 100 connections (ms)
Martin233.6 ms
Tegola1.138 s
BBOX289.3 ms
pg_tileserv1.145 s
TiPg486.2 ms
ldproxy4.963 s
ldproxy (PGIS_TILES)1.140 s
p99 latency, 100 connections in ms
Martin233.6 ms
Tegola1.138 s
BBOX289.3 ms
pg_tileserv1.145 s
TiPg486.2 ms
ldproxy4.963 s
ldproxy (PGIS_TILES)1.140 s

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)

Failed requests, 100 connections (%)
Martin0 %
Tegola0 %
BBOX0 %
pg_tileserv0.1 %
TiPg0 %
ldproxy0 %
ldproxy (PGIS_TILES)0 %
Failed requests, 100 connections in %
Martin0 %
Tegola0 %
BBOX0 %
pg_tileserv0.1 %
TiPg0 %
ldproxy0 %
ldproxy (PGIS_TILES)0 %

Martin sustained 467 tiles/s at 100 connections; ldproxy managed 25

Viewport burst

Four adjacent z15 building 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 z15 building tiles have arrived, requested in parallel. (LOWER ms = FASTER)

Viewport, mean (ms)
Martin5.5 ms
Tegola7.5 ms
BBOX6.6 ms
pg_tileserv5.3 ms
TiPg10.2 ms
ldproxy51.7 ms
ldproxy (PGIS_TILES)10.5 ms
Viewport, mean in ms
Martin5.5 ms
Tegola7.5 ms
BBOX6.6 ms
pg_tileserv5.3 ms
TiPg10.2 ms
ldproxy51.7 ms
ldproxy (PGIS_TILES)10.5 ms

Test 2 Viewport, slowest

The slowest of the measured viewport loads. (LOWER ms = FASTER)

Viewport, slowest (ms)
Martin8.1 ms
Tegola12.2 ms
BBOX9.7 ms
pg_tileserv7.5 ms
TiPg13.6 ms
ldproxy84.8 ms
ldproxy (PGIS_TILES)20.7 ms
Viewport, slowest in ms
Martin8.1 ms
Tegola12.2 ms
BBOX9.7 ms
pg_tileserv7.5 ms
TiPg13.6 ms
ldproxy84.8 ms
ldproxy (PGIS_TILES)20.7 ms

pg_tileserv filled the four-tile viewport in 5.3 ms; ldproxy needed 51.7 ms

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 building tile is served, including schema discovery, over five starts. (LOWER ms = FASTER)

Time to first tile (ms)
Martin133.0 ms
Tegola142.0 ms
BBOX147.0 ms
pg_tileserv149.0 ms
TiPg508.0 ms
ldproxy3.709 s
ldproxy (PGIS_TILES)3.686 s
Time to first tile in ms
Martin133.0 ms
Tegola142.0 ms
BBOX147.0 ms
pg_tileserv149.0 ms
TiPg508.0 ms
ldproxy3.709 s
ldproxy (PGIS_TILES)3.686 s

Test 2 Idle memory

Container RSS five seconds after the first tile, before any load. (LOWER MB = FASTER)

Idle memory (MB)
Martin9.5 MB
Tegola8.1 MB
BBOX10.9 MB
pg_tileserv4.4 MB
TiPg213.6 MB
ldproxy479.4 MB
ldproxy (PGIS_TILES)447.5 MB
Idle memory in MB
Martin9.5 MB
Tegola8.1 MB
BBOX10.9 MB
pg_tileserv4.4 MB
TiPg213.6 MB
ldproxy479.4 MB
ldproxy (PGIS_TILES)447.5 MB

Test 3 Memory after load

Container RSS right after the concurrency passes. (LOWER MB = FASTER)

Memory after load (MB)
Martin39.8 MB
Tegola46.5 MB
BBOX42.2 MB
pg_tileserv41 MB
TiPg240.5 MB
ldproxy3225.6 MB
ldproxy (PGIS_TILES)695.7 MB
Memory after load in MB
Martin39.8 MB
Tegola46.5 MB
BBOX42.2 MB
pg_tileserv41 MB
TiPg240.5 MB
ldproxy3225.6 MB
ldproxy (PGIS_TILES)695.7 MB

Martin served its first tile 133 ms after process start on average; ldproxy took 3.71 s

Test conditions: rig, corpus, protocol
Rig
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); PostGIS 18 / 3.6.1 in its own container (6 CPUs, 12 GB); one tile server container at a time (4 CPUs, 4 GB); oha 1.16.0 on the host
Protocol
Warmups
3 unmeasured passes
Measured
20 passes per tool
Process
One server container at a time against a shared PostGIS container; 8 database connections per server; 4 workers where the concept exists; 5 cold starts, 20 single-tile passes of 5 requests per tile, 20 four-tile viewport bursts, 8 passes of 12 s at 10 and at 100 connections
Cache
No server-side tile cache anywhere; PostgreSQL buffer cache warm after warmups; tiles requested without compression
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 did we learn?

Martin led single-tile latency, throughput and cold start; the five servers that encode in PostGIS finished within 2 ms of each other on one tile, and ldproxy's opt-in PGIS_TILES mode cut its gap from 9x to 1.4x.

Mean of each section's samples per candidate. The best value in each column is marked in red.
CandidateSingle-tile latency (ms, lower is better)Throughput under concurrency (req/s, higher is better)Viewport burst (ms, lower is better)Cold start and footprint (ms, lower is better)
Martin13.6 ms467.4 req/s5.5 ms133.0 ms
Tegola15.6 ms254.6 req/s7.5 ms142.2 ms
BBOX15.2 ms394.6 req/s6.6 ms146.8 ms
pg_tileserv13.7 ms169.3 req/s5.3 ms148.8 ms
TiPg13.8 ms456.2 req/s10.2 ms508.0 ms
ldproxy120.8 ms24.6 req/s51.7 ms3.709 s
ldproxy (PGIS_TILES)18.9 ms226.0 req/s10.5 ms3.686 s

The thesis ranking held at both ends: Martin first, stock ldproxy last. Between them the order moved. TiPg 1.5.0 with four workers went from fifth to second on throughput, Tegola from second to fourth, and ldproxy's PGIS_TILES mode put it in the pack instead of an order of magnitude behind. Every server that lets PostGIS encode the tile landed within a few milliseconds of the others on one client; the differences that matter show up at 100 clients, at cold start, and in memory, where the two Java configurations cost 0.7 and 3.2 GB against 40 to 50 MB for the Rust and Go servers. The scorecard below has the numbers per section; the limitations say what they do not mean.

What this does not prove

  • One laptop: PostGIS (6 CPUs, 12 GB), the server under test (4 CPUs, 4 GB) and the load generator all shared a 12-core M2 Max under OrbStack. Numbers are relative to each other, not absolute capacity.
  • Every server ran an 8-connection database pool because BBOX 0.6.2 hard-codes 8. The 100-client test therefore measures how each server queues 100 clients onto 8 database connections; untuned Tegola (default pool 100) or Martin (default 20) may behave differently.
  • Tiles were requested without Accept-Encoding, as the thesis's JMeter plans did. Tegola gzips every tile internally and decompresses it again for such clients, so its uncompressed path does more work than its gzip path (5% at one client); browser clients, which accept gzip, would narrow Tegola's 100-client gap.
  • The servers do not select exactly the same features: Martin, BBOX and pg_tileserv query the buffered tile envelope (2,123 buildings, 58 boundary polygons), Tegola, TiPg and ldproxy the unbuffered one (2,017 to 2,019 buildings, 55 polygons), and stock ldproxy additionally drops features under half a pixel and simplifies geometry (1,998 buildings; an 11 KB boundary tile against 30 KB). Stock ldproxy did the least encoding work and was still slowest.
  • pg_tileserv's tiles are about 20% larger than Martin's for the same feature set, which its single-connection times absorb but its throughput pays for.
  • ldproxy appears twice on purpose. Stock ldproxy encodes tiles in Java from a feature query; PGIS_TILES (opt-in since 4.4) moves encoding into PostGIS and only supports simple mappings without joins, arrays or transformations. Its JVM was allowed 75% of the 4 GB container, above the 25% default.
  • pg_tileserv's default 10-second database timeout was raised to 60 s so it could not fail requests the other servers would have served; it still closed 25 of 16,280 connections at 100 clients.
  • TiPg ran four gunicorn workers to match the four CPUs; its image defaults to one. One-worker TiPg is a different benchmark.
  • Cold start is process start to first tile, so it includes schema discovery on four tables; ldproxy's also includes auto-generating the API configuration from the provider. Two of ldproxy's five starts took twice as long as the other three.
  • Memory is Docker's reported usage for the container (cgroup usage minus inactive file cache), sampled once idle and once after the 100-client passes.
  • Load repeatedly requested one z14 tile with the PostgreSQL buffer cache warm, so this is server and encoder overhead over a hot database, not cache-miss or cold-disk behaviour, and no CDN or tile cache sits in front.
  • Single run on one day. Martin's throughput dipped 20% in its last two 100-client passes; every sample is in the run file.
  • Martin's tile bytes vary by about 1% between identical requests because PostgreSQL does not fix feature order; the parity check compares feature counts.

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. Tegola is a project of the Go Spatial organization. BBOX is developed by Sourcepole. pg_tileserv is developed by Crunchy Data. TiPg is developed by Development Seed. ldproxy is developed by interactive instruments GmbH. Map data: OpenStreetMap contributors, ODbL, via the Geofabrik Berlin extract of 2026-01-01.