Serving Multi-Layer Vector Tiles in One Query

Pack roads, buildings and labels into a single MVT with one round trip: concatenated ST_AsMVT calls, per-layer zoom rules, and the size budget that keeps the tile drawable.

← Back to Vector Tile Endpoints with ST_AsMVT

This page shows how to return roads, buildings, water and labels as separate named layers inside a single tile response, generated by one database round trip.

Context & When to Use

A map style refers to layers by name: road-major, building-fill, water. If each of those is its own tile endpoint, a viewport showing nine tiles at four layers issues 36 HTTP requests, opens 36 cache entries, and holds 36 connections open against the pool. The rendering cannot start until the slowest of them lands, so the extra parallelism buys nothing — it only multiplies the fixed costs.

Packing them into one tile removes all of that. The Mapbox Vector Tile format is a protobuf whose top level is a repeated layer field, and protobuf message concatenation merges repeated fields. That means layer_a_bytes || layer_b_bytes is a valid two-layer tile, with no re-encoding step. PostGIS can produce both halves in one statement, so the whole tile is one query, one connection and one cache key.

Use this for any base map or multi-theme overlay. Keep layers separate only when their update cadences differ enough that you want distinct cache lifetimes — live vehicle positions alongside static parcel boundaries, for instance, where mixing them would force the slow-changing layer to expire at the fast layer’s rate. The tile mechanics themselves are covered on the ST_AsMVT topic page.

Runnable Implementation

-- One tile, three named layers, one round trip
WITH bounds AS (
    SELECT ST_TileEnvelope($1, $2, $3)                     AS merc,
           ST_Transform(ST_TileEnvelope($1, $2, $3), 4326) AS wgs
),
roads AS (
    SELECT r.id, r.class_code,
           ST_AsMVTGeom(ST_SimplifyPreserveTopology(ST_Transform(r.geom, 3857),
                        tile_tolerance($1)), b.merc, 4096, 64, true) AS geom
    FROM   roads r CROSS JOIN bounds b
    WHERE  r.geom && b.wgs AND r.min_zoom <= $1
),
buildings AS (
    SELECT bl.id, bl.height_m,
           ST_AsMVTGeom(ST_Transform(bl.geom, 3857),
                        b.merc, 4096, 64, true) AS geom
    FROM   buildings bl CROSS JOIN bounds b
    -- Buildings are meaningless below z13; skip the work entirely
    WHERE  $1 >= 13 AND bl.geom && b.wgs
),
labels AS (
    SELECT l.id, l.name, l.rank,
           ST_AsMVTGeom(ST_Transform(l.geom, 3857),
                        b.merc, 4096, 8, false) AS geom   -- points: no clipping
    FROM   place_labels l CROSS JOIN bounds b
    WHERE  l.geom && b.wgs AND l.rank <= GREATEST($1 - 4, 1)
)
SELECT
    COALESCE((SELECT ST_AsMVT(roads.*,     'road',     4096, 'geom')
              FROM roads     WHERE geom IS NOT NULL), ''::bytea) ||
    COALESCE((SELECT ST_AsMVT(buildings.*, 'building', 4096, 'geom')
              FROM buildings WHERE geom IS NOT NULL), ''::bytea) ||
    COALESCE((SELECT ST_AsMVT(labels.*,    'label',    4096, 'geom')
              FROM labels    WHERE geom IS NOT NULL), ''::bytea) AS mvt;

Three details carry the design. Each layer has its own WHERE clause, so zoom rules are per layer rather than global. COALESCE(..., ''::bytea) makes an empty layer contribute nothing instead of turning the whole expression NULL. And the label layer passes clip_geom = false with a small buffer, because clipping a point is pointless and a label just outside the tile still needs to exist for collision detection.

Three CTEs, three ST_AsMVT calls, one concatenated tileThree parallel branches from a shared bounds CTE. The road branch filters by min_zoom and simplifies, producing a 34 kilobyte layer. The building branch is skipped entirely below zoom 13 and otherwise produces 51 kilobytes. The label branch filters by rank derived from the zoom and does not clip, producing 6 kilobytes. The three bytea results are concatenated with the double-pipe operator into a single 91 kilobyte protobuf, which the renderer reads as three named layers.One statement, three layers, one responsebounds CTEenvelope ×2 systemsroadmin_zoom gate · simplify · 64-unit buffer · clipped34 KBbuildingskipped entirely below z13 · clipped51 KBlabelrank ≤ z−4 · not clipped · 8-unit buffer6 KBbytea || bytea || bytea91 KB · 3 named layersProtobuf concatenation merges repeated fields, so appending encoded layers is the documented way to build the tile.No re-encoding, no intermediate parse, one connection held for one statement.

Key Parameters & Options

ChoiceRecommendedWhy
Layer name in ST_AsMVTmatches the style’s source-layerThe renderer looks it up by string; a typo silently renders nothing
COALESCE(…, ''::bytea)alwaysOne empty layer would otherwise null the entire concatenation
Per-layer zoom gatein the CTE WHERESkips the scan, not just the encode
clip_geomtrue for lines and polygons, false for pointsClipping a point can only remove it
Buffer64 for lines/polygons, 8 for pointsPoints need only enough room for label collision
Layer ordercheap layers firstThe statement short-circuits nothing, but the plan reads better and profiles cleanly

Budgeting the combined size

The single risk of a combined tile is that the total quietly grows past what a mobile client can decode smoothly. Track it per layer, and thin the layer contributing most before reaching for global simplification.

Combined tile size by zoom, per layerStacked bars for zooms 10 through 16. At zoom 10 the tile is 47 kilobytes, almost all roads. At zoom 12 it is 88 kilobytes. At zoom 13 buildings switch on and the total jumps to 174. At zoom 14 it reaches 232 and at zoom 16 it reaches 470, just under the 500 kilobyte budget line, with buildings contributing the majority. The chart makes clear that the building layer, not roads, is what to thin if the budget is exceeded.Combined tile size, dense urban columnroadbuildinglabel500 KB250 KB0practical budgetz1047z1288z13174z14232z16470Roads stay flat; buildings drive the growth. Thin the building layer at z16, not the whole tile.

Gotchas & Failure Modes

  • A NULL layer nulls the tile. Without COALESCE, a zoom level where one layer is empty returns NULL for the whole concatenation and the route sends a blank tile — see Debugging Empty Vector Tiles.
  • Layer names drifting from the style. The renderer matches source-layer by exact string. Keep the names in one constant shared by the SQL and the style, and assert on the decoded layer set in tests.
  • Duplicated attribute dictionaries. Each layer carries its own keys and values tables, so a shared attribute repeated across six layers is stored six times. Another reason to keep layer counts moderate.
  • One slow layer holding the statement. The combined query is as slow as its slowest CTE. Profile per layer before assuming the tile is uniformly expensive, and consider materialized views for the one that dominates.
  • Cache invalidation across mixed cadences. A combined tile expires at the shortest lifetime of any layer inside it. If one layer changes every minute, split it out rather than dragging the others down.
Fixed costs of splitting layers across endpointsFor a viewport showing nine tiles with four layers, the combined design issues 9 HTTP requests, holds 9 database connections and creates 9 cache entries. The split design issues 36 requests, holds 36 connections and creates 36 cache entries, while the rendered result is identical. Time to first render is 118 milliseconds combined against 260 split, because the client waits for the slowest of four times as many responses.One viewport, 9 tiles, 4 layerscombinedsplit per layerHTTP requests936DB connections held936cache entries created936time to first render118 ms260 msIdentical pixels. The split version simply waits for the slowest of four times as many responses.

Deciding what belongs in the same tile

The grouping decision is about change rate and audience, not about what looks tidy in the style file. Two layers belong together when they are always drawn together and change on similar timescales; they belong apart when either of those breaks.

Road geometry and building footprints change monthly at most, are drawn on every request, and share a cache lifetime measured in hours — one tile. Live vehicle positions change every few seconds and would drag that hours-long lifetime down to nothing, so they belong in their own endpoint with its own short max-age, layered client-side over the base tile.

Access control is the second splitter. If one layer is public and another requires a scope check, keeping them in the same tile means the tile itself becomes privileged and the public layer stops being cacheable at the edge. Split by sensitivity so the public half can be served from a shared cache and only the restricted half carries a per-caller cache key — the pattern described in JWT Authentication for Spatial Scopes.

A useful test when in doubt: if you would ever want to invalidate one layer without the other, they are two tiles.

Verification Snippet

import mapbox_vector_tile, requests

r = requests.get("http://localhost:8000/v1/tiles/14/8188/5448.mvt")
tile = mapbox_vector_tile.decode(r.content)

assert set(tile) == {"road", "building", "label"}, set(tile)
for name, layer in tile.items():
    print(f"{name:9} {len(layer['features']):5} features  extent={layer['extent']}")
# road       1180 features  extent=4096
# building    842 features  extent=4096
# label        37 features  extent=4096
-- Per-layer byte contribution for one tile, to find what to thin
SELECT 'road' AS layer, octet_length((SELECT ST_AsMVT(r.*, 'road', 4096, 'geom') FROM roads r)) AS bytes
UNION ALL
SELECT 'building', octet_length((SELECT ST_AsMVT(b.*, 'building', 4096, 'geom') FROM buildings b));

← Back to Vector Tile Endpoints with ST_AsMVT