Martin vs tileserver-gl for Production Delivery
Bottom line: use Martin when you serve vector tiles only and want the highest throughput per core from MBTiles, PMTiles, or PostGIS; use tileserver-gl when you also need server-side raster images or a single process that hosts the style, glyphs, and sprites alongside the tiles. They are not really competitors so much as tools for two different jobs, and picking the wrong one shows up as either wasted CPU or a missing capability.
When to Use Each
Reach for Martin when:
- The map is vector-only and driven by a client renderer like MapLibre GL.
- Throughput matters — a public basemap, high concurrency, tight per-core budgets.
- The source is PostGIS and you want live
ST_AsMVTtiles, or a mix of MBTiles, PMTiles, and PostGIS behind one binary. - You want a single static Rust binary with a small memory footprint and no runtime to patch.
Reach for tileserver-gl when:
- You need server-side raster tiles or static map images (
/static/{lon},{lat},{zoom}/{w}x{h}.png) — legacy raster clients, PDF export, social preview thumbnails, email. - You want one process to host the style JSON, glyph (font) PBFs, and sprite sheets that MapLibre needs, not just the tiles.
- Traffic is moderate and served predominantly through a CDN, so the Node event loop is not the bottleneck.
If neither raster nor bundled asset hosting is in play, the decision collapses to Martin, and the broader engine landscape — including pg_tileserv — is laid out in the tile server selection guide.
Specification Comparison
| Dimension | Martin | tileserver-gl |
|---|---|---|
| Runtime | Rust (static binary) | Node.js + Mapbox GL native |
| MBTiles source | yes | yes |
| PMTiles source | yes (local + HTTP) | no |
PostGIS ST_AsMVT |
yes (tables + functions) | no |
Vector tiles /{z}/{x}/{y} |
yes | yes |
Raster tiles .png/.jpg |
no | yes (rendered) |
| Static image API | no | yes (/styles/{id}/static/...) |
/style (style JSON) hosting |
no | yes |
/sprite (icons) hosting |
no | yes |
/font (glyph PBF) hosting |
no | yes |
| TileJSON endpoint | yes (/catalog, per-source) |
yes |
| Config | CLI args or config.yaml |
config.json |
| Docker image | ghcr.io/maplibre/martin |
maptiler/tileserver-gl |
The rows that decide most real deployments are raster rendering and the /style, /sprite, /font trio: those are exactly what Martin does not do and what tileserver-gl exists to provide. Everything else — MBTiles reads, TileJSON, the tile URL template — both handle.
Production Command Block
Both servers run cleanly in Docker behind a CDN. Martin with a mixed PostGIS-plus-MBTiles catalog:
# martin/config.yaml
listen_addresses: "0.0.0.0:3000"
postgres:
connection_string: "postgresql://tiles_ro:[email protected]:5432/gis"
pool_size: 40
mbtiles:
paths:
- /data/roads.mbtiles # served as source "roads"
- /data/buildings.mbtiles # served as source "buildings"
# Run Martin; --config wires up both PostGIS and the MBTiles files
docker run -d --name martin -p 3000:3000 \
-v "$PWD/martin:/config" -v /data:/data \
ghcr.io/maplibre/martin:latest \
martin --config /config/config.yaml
# Catalog: http://localhost:3000/catalog
tileserver-gl serving tiles plus the style, glyphs, and sprites MapLibre needs:
// tileserver/config.json
{
"options": { "paths": { "root": "/data", "mbtiles": "/data" } },
"styles": {
"basemap": { "style": "basemap/style.json", "serve_rendered": true }
},
"data": {
"roads": { "mbtiles": "roads.mbtiles" }
}
}
# serve_rendered:true enables the raster + static-image endpoints
docker run -d --name tileserver -p 8080:8080 \
-v /data:/data maptiler/tileserver-gl \
--config /data/config.json
# Vector: http://localhost:8080/data/roads/{z}/{x}/{y}.pbf
# Raster: http://localhost:8080/styles/basemap/{z}/{x}/{y}.png
# Glyphs: http://localhost:8080/fonts/{fontstack}/{range}.pbf
# Sprite: http://localhost:8080/styles/basemap/sprite.png
Interaction Effects
Neither server should face the public directly — both belong behind a CDN with Cache-Control set on the edge response, so the origin only generates cold tiles. That single fact largely neutralizes tileserver-gl’s lower raw throughput for cacheable basemaps: if 95%+ of requests are edge hits, the Node event loop rarely sees them. The header and versioning rules that make this work are in CDN cache headers and versioning.
Martin’s PostGIS pathway introduces one interaction the other lacks: the connection pool_size must be sized against the database max_connections across all replicas, or you trade tile latency for too many clients errors. tileserver-gl has no such database coupling, but its serve_rendered mode pulls in Mapbox GL native and a headless GL stack, which raises the memory floor and the cold-start time considerably.
On the consumer side, tileserver-gl’s /style, /font, and /sprite endpoints feed a MapLibre client directly — one host answers for the style document and every asset it references. With Martin you host those assets elsewhere (a static bucket or the CDN) and Martin serves only tiles; the MapLibre GL JSON structure determines where the style points for each. If you would rather run no server at all, the static route is PMTiles range-request delivery straight from object storage.
Performance Impact
Numbers are directional and hardware-dependent, but the shape of the difference is consistent across deployments:
| Metric | Martin | tileserver-gl (vector) | tileserver-gl (raster render) |
|---|---|---|---|
| Vector throughput per core | very high (thousands of tiles/s) | moderate | n/a |
| Tail latency, cached MBTiles tile | ~1–5 ms | ~5–20 ms | 50–300 ms (render) |
| Idle memory | tens of MB | ~150–300 MB | 400 MB–1 GB+ (GL stack) |
| Cold start | near-instant (static binary) | seconds (Node init) | seconds + GL warm-up |
| Scaling model | near-linear on cores | plateaus on event loop | CPU-bound per render |
The practical read: for vector-only serving at scale, Martin does more work per core with far less memory, so you run fewer, smaller instances. tileserver-gl’s cost is justified only by the raster rendering and asset-hosting it uniquely provides — paying that cost to serve plain vector tiles at high volume is the classic misfit. The underlying MBTiles both read has its own architecture limits — SQLite locking and container size — that cap serving throughput regardless of which engine sits in front.
Common Mistakes
1. Using tileserver-gl purely for vector at scale. Teams pick it for its friendly UI, then hit the Node event loop as a throughput ceiling under real concurrency — latency climbs and CPU saturates on a single core. Symptom: p99 tile latency rising with load while CPU pins one core. Fix: switch the vector-only workload to Martin, or ensure the CDN absorbs nearly all reads so the origin barely runs.
# If origin CPU tracks user traffic, the cache — not the server — is the problem
curl -sI "https://tiles.example.com/v3/roads/10/301/384" | grep -i cf-cache-status
# Want HIT on the vast majority of tile requests.
2. Not caching at the CDN. Both servers are fast enough when they answer each tile once; neither is fast enough to answer every tile on every page load. A missing or no-store Cache-Control turns the origin into the bottleneck. Fix: set Cache-Control: public, max-age=31536000, immutable on versioned tile paths at the edge.
3. SRID / projection mismatch. With Martin’s PostGIS sources, geometry stored in EPSG:4326 while MVT expects EPSG:3857 yields tiles that return 200 but place features in the wrong location or empty. Symptom: valid-looking tiles with data in the ocean. Fix: store or reproject geometries to 3857 before encoding.
psql "$DATABASE_URL" -c "SELECT ST_SRID(geom) FROM public.parcels LIMIT 1;" # expect 3857
Parent: Tile Server Selection
Related
- Tile Server Selection: Martin, tileserver-gl & pg_tileserv — the parent comparison that adds pg_tileserv and the static-vs-dynamic decision to this two-way head-to-head.
- PMTiles Range-Request Delivery from Object Storage — the no-server alternative that removes both engines from the picture by serving a static archive over HTTP ranges.
- MBTiles Architecture & Limits — the SQLite container both servers read, and the locking and size constraints that bound serving throughput.