Short answer
PostGIS is a strong web-mapping backend because it can turn a spatial query into a Mapbox Vector Tile (MVT) on the fly with ST_AsMVT, so the database itself becomes the tile server. The browser requests a tile at z/x/y, PostGIS reads only the rows in that tile's envelope through a GiST index, clips and encodes them into a compact binary blob, and a thin HTTP service streams it back. Done well — indexed geometry, per-zoom simplification, and an HTTP cache — a single Postgres instance comfortably drives an interactive map. The failure modes are predictable: missing index, no simplification, and serving full-precision GeoJSON instead of tiles.
The tile request lifecycle
A web map client (MapLibre GL JS, OpenLayers, Leaflet with a vector plugin) divides the world into a quadtree of tiles addressed by zoom z, column x, and row y, using the Web Mercator grid (EPSG:3857). When the user pans, the client requests the tiles now in view. Your backend's job is to answer each z/x/y request with the features inside that tile, at appropriate detail.
The whole pipeline can be one SQL statement:
SELECT ST_AsMVT(tile, 'roads', 4096, 'geom')
FROM (
SELECT
id, name, road_class,
ST_AsMVTGeom(
ST_Transform(geom, 3857),
ST_TileEnvelope(:z, :x, :y),
4096, 64, true
) AS geom
FROM roads
WHERE ST_Transform(geom, 3857) && ST_TileEnvelope(:z, :x, :y)
) AS tile
WHERE tile.geom IS NOT NULL;
Reading inside-out: ST_TileEnvelope(z,x,y) builds the tile's bounding box in EPSG:3857. The && bounding-box operator, backed by a spatial index, filters to candidate rows. ST_AsMVTGeom clips each geometry to the tile, snaps it to the tile's integer coordinate grid (4096 extent, 64-unit buffer to avoid edge clipping artefacts, true to clip), and discards anything fully outside. ST_AsMVT aggregates the survivors into one binary tile in a layer named roads. The result is a bytea you return with Content-Type: application/vnd.mapbox-vector-tile.
Why MVT beats serving GeoJSON
The naive backend serves ST_AsGeoJSON of a whole layer. It works at small scale and falls apart fast:
- Payload size. GeoJSON is verbose text with full-precision coordinates. A coastline that is 40 MB of GeoJSON is a few hundred kilobytes of MVT once clipped and quantised to a tile.
- Detail control. A tile is naturally scale-limited — you only send what is in view at that zoom. A whole-layer GeoJSON sends everything regardless of the viewport.
- Client cost. The browser parses and indexes MVT efficiently; megabytes of JSON block the main thread.
GeoJSON remains the right answer for small, dynamic overlays (a handful of editable features, a query result of a few hundred rows). For base layers and large datasets, tiles win decisively.
Serving the tiles: build vs buy
You rarely call PostGIS directly from the browser. Two common patterns:
pg_tileserv (from the PostGIS/Crunchy ecosystem) auto-publishes every spatial table as an MVT endpoint at /{schema}.{table}/{z}/{x}/{y}.pbf, running the ST_AsMVT query for you. It is the fastest way to a working vector-tile service and supports function layers for parameterised queries.
A custom API (a small Node, Python/FastAPI, or Go service) is worth it when you need auth, per-tenant filtering, computed attributes, or to merge several tables into one tile. The service just substitutes z/x/y into the query above and returns the bytea.
Either way, put an HTTP cache (a CDN, or Nginx with proxy_cache) in front, keyed on the tile path. Most tile requests repeat, and caching turns a database hit into a static file response.
Live tiles vs pre-rendering
Generate tiles on demand when data changes and traffic is moderate — most internal dashboards and client portals. Pre-render to static .pbf files or an MBTiles archive when data is static, traffic is high, or low-zoom tiles must aggregate millions of features (those queries are expensive every time). GDAL can pre-bake a tile pyramid:
ogr2ogr -f MVT tiles_out data.gpkg \
-dsco MINZOOM=0 -dsco MAXZOOM=14 \
-dsco COMPRESS=YES
A common hybrid: pre-render stable base layers, serve volatile layers live, and overlay them in the same map.
Performance: the four levers
-
Spatial index.
CREATE INDEX roads_geom_gix ON roads USING GIST (geom);is non-negotiable. Without it every tile triggers a full scan. If your data is stored in 4326 but tiled in 3857, index a generated 3857 column or a functional index so the&&filter stays sargable rather than transforming every row. -
Per-zoom simplification. A 1 m-accurate geometry is wasted at zoom 4, where one pixel covers kilometres. Simplify by zoom:
ST_AsMVTGeom(
ST_Simplify(ST_Transform(geom, 3857), tolerance_for(:z)),
ST_TileEnvelope(:z, :x, :y), 4096, 64, true)
Use a tolerance roughly equal to one tile-pixel in metres at that zoom (Web Mercator ground resolution at the equator is about 156543 / 2^z metres per pixel). Pre-compute simplified columns for the heaviest layers rather than simplifying on every request.
-
Feature limits at low zoom. Cap features (
LIMIT, or filter by an importance/road_classattribute) so zoomed-out tiles do not try to encode an entire country. -
HTTP caching. Cache by
z/x/y. This is the single biggest win under real traffic.
Always profile with EXPLAIN (ANALYZE, BUFFERS) on a representative low-zoom tile — that is where queries blow up — and confirm the GiST index appears in the plan.
Validation before you ship
- Request a known tile and confirm a non-empty
byteaand the right content type. - Decode a tile (
ogrinforeads MVT) and check the expected layer name and feature count. - Verify alignment: features should not visibly jump at tile boundaries (a buffer too small in
ST_AsMVTGeomcauses hairline gaps). - Load-test a low-zoom tile cold; that is your worst case.
Bathyl perspective
We use PostGIS as a tile backend when a map needs to be live, queryable, and tied to data that changes — not a frozen export. The discipline is the same as any data product: index the geometry, control detail per zoom, cache aggressively, and keep one authoritative table behind the service so the web map and the analysis read the same truth.
Related reading
- PostGIS for Geological Map Databases
- PostGIS vs Desktop GIS
- Shapefile vs GeoPackage vs GeoJSON
- Spatial data products