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

What each server is actually built to doTwo panels contrasting Martin, a lean vector tile server that also speaks PostGIS, with tileserver-gl, which additionally renders raster tiles from a style.TWO SERVERSMartinMBTiles, PMTiles and PostGIS sourcesNo raster renderingLow, flat memory footprintOne TOML file of configurationThe default for vector-only deliverytileserver-glMBTiles sourcesRenders raster tiles and static imagesCarries a full rendering stack inmemoryNeeds styles, fonts and sprites on diskRight when raster output is arequirement
The deciding question is whether you need raster output. If not, the renderer in tileserver-gl is memory you are paying for and not using.

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_AsMVT tiles, 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:

bash
# 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"
bash
# 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:

json
// tileserver/config.json
{
  "options": { "paths": { "root": "/data", "mbtiles": "/data" } },
  "styles": {
    "basemap": { "style": "basemap/style.json", "serve_rendered": true }
  },
  "data": {
    "roads": { "mbtiles": "roads.mbtiles" }
  }
}
bash
# 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

What each configuration costs to keep runningFour server configurations measured for steady-state resident memory: Martin on files, Martin on PostGIS, tileserver-gl serving vector tiles, and tileserver-gl rendering raster.RESIDENT MEMORYMB resident, steady stateMartin, MBTiles/PMTiles48 MBMartin, PostGIS source96 MBtileserver-gl, vector only320 MBtileserver-gl, raster render1.4 GB
The last row is the renderer. It is why a raster-capable server is sized differently from a vector one even when it is serving the same tiles.

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

What to verify on a tile server before it takes trafficFive production checks: Content-Encoding correctness, Cache-Control emission, TileJSON accuracy, connection pool sizing and a health endpoint.BEFORE PRODUCTIONContent-Encoding matches the stored bytesthe double-gzip blank map starts hereCache-Control is emitted on every tile responsewithout it the edge picks its own short defaultTileJSON minzoom and maxzoom match the archivea wrong range makes the client request tiles that do not existPostGIS pool sized above peak concurrencyexhaustion appears as latency, not as errorsHealth endpoint returns without touching the databasea health check that queries will fail the whole fleet during adatabase blip
Four of these fail quietly under load rather than at startup, which is why they belong in a smoke test rather than a runbook.

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.

bash
# 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.

bash
psql "$DATABASE_URL" -c "SELECT ST_SRID(geom) FROM public.parcels LIMIT 1;"  # expect 3857

Parent: Tile Server Selection

FAQ

Which is the safer default?

Martin, unless raster output is a requirement. It serves files and PostGIS, has a small flat memory footprint, and its configuration is one file.

When is tileserver-gl clearly right?

When static images are needed — print exports, report thumbnails, link previews. Rendering is what it has that Martin does not, and it is the only reason to pay for the renderer.

Can I run both?

Yes, and it is a reasonable arrangement: Martin for the interactive map, tileserver-gl for occasional raster rendering, each scaled independently.

Do they differ in tile output?

No. Both emit standard MVT, so the choice is operational rather than one that affects what a reader receives.