How to Generate and Cache Mapbox Vector Tiles with PostGIS

Generating a Mapbox Vector Tile in PostGIS is straightforward. Caching it without serving stale geometry is the real problem.
A basic implementation needs three PostGIS functions and one spatial query. A reliable cached implementation needs two additional properties:
Every data change must identify the old and new tiles it affects.
A cached tile must be replaced before its CDN URL is purged.
Everything else is an optimization.
This article starts with a direct ST_AsMVT query, exposes it through Laravel, and then moves tile generation into a background job backed by a versioned cache table. The resulting request path performs one primary-key lookup, while failed jobs and failed CDN purges remain recoverable from database state.
A Mapbox Vector Tile is a format, not a hosted service
“Mapbox Vector Tile” refers to a binary format, usually abbreviated to MVT. It does not require Mapbox hosting. MapLibre, Mapbox GL, OpenLayers, and other clients can render the same format.
An MVT contains one or more named layers. Each layer contains geometries and attributes. Styling remains the client’s responsibility.
PostGIS provides the three functions needed for the common XYZ-tile workflow:
ST_TileEnvelopereturns the bounds of an XYZ tile, normally in Web Mercator (EPSG:3857).ST_AsMVTGeomtransforms and clips geometry into the integer coordinate space used inside a tile.ST_AsMVTaggregates rows into a binary MVT layer.
The coordinate conversion matters. A longitude and latitude are not written directly into the tile. The geometry is transformed to Web Mercator and then mapped into a local integer grid—commonly 4096 units wide—inside the requested tile.
Create a spatial table
Assume a table of public locations stored as points in EPSG:4326:
CREATE TABLE locations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
is_visible boolean NOT NULL DEFAULT false,
location geometry(Point, 4326) NOT NULL
);
The spatial index is not optional once the table grows:
CREATE INDEX locations_location_gist
ON locations
USING gist (location);
PostgreSQL can use this index for bounding-box predicates. Without it, each tile request can degrade into scanning every location.
Generate one vector tile directly from PostGIS
A minimal query looks like this:
WITH bounds AS (
SELECT
ST_TileEnvelope(:z, :x, :y) AS tile_bounds,
ST_Transform(ST_TileEnvelope(:z, :x, :y), 4326) AS query_bounds
), features AS (
SELECT
locations.id,
locations.name,
ST_AsMVTGeom(
ST_Transform(locations.location, 3857),
bounds.tile_bounds,
4096,
64,
true
) AS geom
FROM locations
CROSS JOIN bounds
WHERE locations.is_visible
AND locations.location && bounds.query_bounds
AND ST_Intersects(locations.location, bounds.query_bounds)
)
SELECT COALESCE(
ST_AsMVT(features.*, 'locations', 4096, 'geom'),
''::bytea
) AS contents
FROM features;
The query has four stages:
ST_TileEnvelopecalculates the requested XYZ tile bounds in Web Mercator.The bounds are transformed to
EPSG:4326so they can be compared with the stored points.ST_AsMVTGeomtransforms each matching point into local tile coordinates.ST_AsMVTencodes the rows into a layer namedlocations.
The two spatial predicates serve different purposes:
locations.location && bounds.query_bounds
This is a bounding-box test. PostgreSQL can use the GiST index to discard most rows cheaply.
ST_Intersects(locations.location, bounds.query_bounds)
This performs the exact spatial test. The distinction becomes more important when the source contains lines or polygons rather than points.
The extent of 4096 defines the tile’s internal coordinate grid. The buffer of 64 allows geometry near the edge to survive clipping. The final true tells ST_AsMVTGeom to clip geometry to the buffered tile bounds.
Decide how boundary points behave
A point exactly on a tile boundary may intersect two adjacent envelopes. For independent markers, duplicate rendering is usually undesirable. Half-open bounds assign every point to one tile:
AND ST_X(locations.location) >= ST_XMin(bounds.query_bounds)
AND ST_X(locations.location) < ST_XMax(bounds.query_bounds)
AND ST_Y(locations.location) >= ST_YMin(bounds.query_bounds)
AND ST_Y(locations.location) < ST_YMax(bounds.query_bounds)
This rule is suitable for points. Lines and polygons legitimately cross tile boundaries, so they require different handling. Buffered labels may also need features outside the visible envelope. Boundary ownership should follow the geometry being rendered rather than being copied mechanically from a point example.
Return the tile from Laravel
The HTTP endpoint must validate the XYZ coordinate before querying PostGIS. At zoom z, both x and y must fall between zero and (2^z) - 1.
final readonly class ShowMapTileController
{
public function __construct(private MapTileReader $tiles) {}
public function __invoke(Request $request, int $z, int $x, int $y): Response
{
abort_unless($z >= 0 && $z <= 18, 404);
$tileCount = 2 ** $z;
abort_unless(
$x >= 0 && $y >= 0 && $x < $tileCount && $y < $tileCount,
404,
);
$tile = $this->tiles->read($z, $x, $y);
$response = response($tile->contents, 200, [
'Content-Type' => 'application/vnd.mapbox-vector-tile',
]);
$response->setEtag($tile->etag);
$response->setPublic();
$response->setMaxAge(60);
$response->headers->addCacheControlDirective('must-revalidate');
$response->isNotModified($request);
return $response;
}
}
The ETag should be a stable hash of the binary payload:
$etag = hash('sha256', $contents);
An ETag avoids retransmitting an unchanged response. It does not necessarily avoid the SQL query. If Laravel regenerates the tile before comparing the hash with If-None-Match, a 304 Not Modified response saves bandwidth but not database work.
A CDN avoids the origin request while its cached response remains fresh. That improves the common path, but a cache miss still executes the complete spatial query. More importantly, the CDN does not know when a location has moved or disappeared.
Why a TTL is not enough
A short cache TTL is the simplest invalidation strategy. It is also a freshness limit.
With a 60-second TTL, a deleted marker may remain visible for up to a minute. Raising the TTL improves the cache-hit ratio but extends that stale period. Lowering it increases origin traffic. The trade-off cannot be removed by choosing a clever number.
Exact invalidation solves the freshness problem, but it creates a consistency requirement. When a location changes, the application must purge every cached tile affected by the change.
For a newly visible location, that means its new tile at every supported zoom. For a hidden or deleted location, it means its previous tiles. For a moved location, it means both sets.
Purging only the new coordinate is a common error. The marker appears at its destination while remaining cached at its origin.
Convert longitude and latitude to XYZ coordinates
The application can calculate the affected tile for each zoom without running a spatial query:
function tileCoordinate(float $longitude, float $latitude, int $zoom): array
{
$maximumLatitude = 85.0511287798066;
$longitude = max(-180.0, min(180.0, $longitude));
$latitude = max(-$maximumLatitude, min($maximumLatitude, $latitude));
$tileCount = 2 ** $zoom;
$latitudeRadians = deg2rad($latitude);
$x = (int) floor((($longitude + 180.0) / 360.0) * $tileCount);
$y = (int) floor(
(1.0 - asinh(tan($latitudeRadians)) / M_PI)
/ 2.0
* $tileCount,
);
return [
max(0, min($tileCount - 1, $x)),
max(0, min($tileCount - 1, $y)),
];
}
For every mutation, calculate coordinates from both the previous and current geometry, then deduplicate them:
$dirtyTiles = [];
foreach ([$before, $after] as $point) {
if ($point === null) {
continue;
}
for ($zoom = 0; $zoom <= 18; $zoom++) {
[$x, $y] = tileCoordinate($point->longitude, $point->latitude, $zoom);
$dirtyTiles["{$zoom}/{$x}/{$y}"] = [$zoom, $x, $y];
}
}
The actual zoom range should match the source configuration used by the map. There is no reason to generate or invalidate tiles the client will never request.
For points, this coordinate calculation identifies one tile per zoom. Lines and polygons require all tiles intersecting the old and new geometry. PostGIS is better suited to calculating that set.
Store generated tiles as versioned artifacts
The simplest useful cache table stores the binary payload and enough state to determine whether it is current:
CREATE SEQUENCE map_tile_version_seq AS bigint;
CREATE TABLE map_tiles (
zoom smallint NOT NULL,
tile_x integer NOT NULL,
tile_y integer NOT NULL,
contents bytea,
etag char(64),
required_version bigint NOT NULL,
generated_version bigint,
purged_version bigint,
generated_at timestamptz,
PRIMARY KEY (zoom, tile_x, tile_y)
);
The three versions have separate meanings:
required_version
The newest data version the tile must represent.
generated_version
The data version represented by the stored bytes.
purged_version
The generated version successfully removed from the CDN cache.
A tile is dirty when no artifact exists or its generated version is behind:
generated_version IS NULL
OR generated_version < required_version
A partial index keeps recovery scans focused on those rows:
CREATE INDEX map_tiles_dirty_index
ON map_tiles (required_version, zoom, tile_x, tile_y)
WHERE generated_version IS NULL
OR generated_version < required_version;
This is more than cache metadata. It is a record of incomplete work. If generation succeeds but CDN invalidation fails, the database can distinguish that state from a tile that was never generated.
Mark tiles dirty inside the data transaction
When a location changes, update the location and mark the affected tiles dirty in the same database transaction.
The sequence is:
Lock and read the current location.
Apply the insert, update, visibility change, or deletion.
Calculate the union of old and new XYZ coordinates.
Obtain a new version from
map_tile_version_seq.Upsert every affected tile with that required version.
Commit.
Dispatch background generation jobs.
The upsert must never move a requirement backwards:
INSERT INTO map_tiles (
zoom,
tile_x,
tile_y,
required_version
) VALUES (:z, :x, :y, :version)
ON CONFLICT (zoom, tile_x, tile_y)
DO UPDATE SET required_version = GREATEST(
map_tiles.required_version,
EXCLUDED.required_version
);
Suppose version 40 moves a location into a tile. Before its job runs, version 41 moves the same location again. GREATEST preserves version 41 even if an older job was already dispatched.
Keeping dirty state in the same transaction as the source update closes another failure window. If the transaction rolls back, neither the location nor its tile requirement changes. If it commits, the database retains evidence that regeneration is required even if queue dispatch subsequently fails.
Laravel jobs that depend on committed rows should be dispatched after commit. Otherwise a fast worker may execute before the source transaction becomes visible—or process work for a transaction that eventually rolls back.
Generate tiles outside the request path
The worker receives one or more XYZ coordinates. For each coordinate, it should:
Start a transaction.
Lock the
map_tilesrow.Compare
generated_versionwithrequired_version.Skip generation when the stored artifact is already current.
Run the same
ST_AsMVTquery used by the direct endpoint.Store the binary payload, ETag, timestamp, and required version.
Commit the completed artifact.
Purge the exact CDN URL, usually in a separate retryable job.
After the CDN confirms success, record the purged version.
The core state check is:
if (
$tile->generated_version !== null
&& $tile->generated_version >= $tile->required_version
) {
// The bytes are current. Only a failed purge may still need retrying.
}
The worker generates the version required when it obtains the row lock, not the version that existed when the job was dispatched. This allows duplicate and delayed jobs to coalesce safely.
Once generation moves to the queue, the public reader becomes a primary-key lookup:
SELECT contents, etag
FROM map_tiles
WHERE zoom = :z
AND tile_x = :x
AND tile_y = :y;
A missing tile should normally return a valid empty MVT response rather than a JSON error. From the map client’s perspective, a tile containing no features is not an exceptional condition.
Store first, purge second
The order between origin storage and CDN invalidation determines whether stale content can be re-cached.
If the application purges first, the following race is possible:
The CDN removes the old tile.
A request reaches the origin before replacement generation finishes.
The origin returns the old tile.
The CDN caches it again.
The purge succeeded, but the stale object returned immediately.
The safe order is:
Generate the replacement.
Store and commit it.
Purge the exact CDN URL.
Now the next cache miss can only retrieve the replacement artifact from the origin.
Cloudflare’s single-file purge removes a named URL across its CDN. The next request fetches the current origin response and caches it again. Other CDNs expose comparable operations, but their cache-key and purge semantics should be verified rather than assumed.
The purged_version column makes purge failure recoverable. If generation succeeds at version 41 but the purge request fails, the row records:
generated_version = 41
purged_version = 40
A retry can skip PostGIS generation and retry only the purge. Without separate state, the worker either regenerates unnecessarily or assumes an external side effect succeeded.
Recovery must not depend on the original queue message
Queues lose work through failed dispatches, exhausted retries, worker restarts, and deployment mistakes. A reliable cache cannot require every message to execute exactly once.
Because dirty state is stored in PostgreSQL, a recovery command can scan it directly:
SELECT zoom, tile_x, tile_y
FROM map_tiles
WHERE generated_version IS NULL
OR generated_version < required_version
ORDER BY zoom, tile_x, tile_y
LIMIT 100;
Use keyset pagination rather than repeatedly loading the complete dirty set. Dispatch bounded batches and let row locks make duplicate jobs harmless.
A second recovery path should find generated tiles whose purge has not been queued:
SELECT zoom, tile_x, tile_y
FROM map_tiles
WHERE generated_version IS NOT NULL
AND (
purged_version IS NULL
OR purged_version < generated_version
);
These queries reconstruct outstanding work from durable state. The queue becomes a delivery mechanism, not the source of truth.
Clustering is an optional second optimization
Do not introduce a cluster projection merely because the map contains markers. First measure tile generation.
For moderate datasets, clustering can remain inside the background ST_AsMVT query. The public request no longer pays for it because it reads stored bytes. That may be sufficient.
If clustering itself becomes expensive, precompute membership in fixed cells. For example, divide a 512-pixel tile into an 8-by-8 grid of 64-pixel cells and store, for each cell:
Feature count
Sum of Web Mercator X coordinates
Sum of Web Mercator Y coordinates
The cluster position is then:
x = sum_x / count
y = sum_y / count
Adding or removing a point updates three values instead of rescanning the complete cell. This approach is deterministic and cheap, but points on opposite sides of a cell boundary do not cluster even when visually close. It should be adopted because that trade-off fits the map, not because it is architecturally elaborate.
The performance change to measure
In one implementation, background tile generation from prepared data took approximately 1.7–1.8 milliseconds. Reading a stored tile through Laravel took approximately 0.23 milliseconds.
Those figures came from an ephemeral local benchmark, not a production workload. They should not be treated as universal PostGIS results. The useful finding is structural: the public request no longer depended on spatial selection, clipping, grouping, or MVT encoding. Its cost became one indexed artifact lookup.
Measure at least:
Spatial query time
MVT encoding time
Tile payload size
Number of tiles requested per viewport
CDN hit ratio
Stored-tile read time
Dirty tiles produced per mutation
Queue delay between mutation and replacement generation
Purge failures and retries
Precomputation moves work rather than removing it. It trades storage and write-path complexity for predictable reads.
When direct generation is enough
Keep direct request-time generation when:
The table is small and properly indexed.
Tile queries remain cheap under realistic viewport traffic.
A short TTL provides acceptable freshness.
Data changes rarely.
Tile attributes vary by request in ways that prevent sharing.
Add stored tile artifacts when:
Cache misses create visible latency or database spikes.
Many users request the same tiles.
A longer CDN TTL would help but stale markers are unacceptable.
Tile generation includes expensive clipping, aggregation, or clustering.
Public request latency must not depend on spatial query complexity.
Add membership or clustering projections only when background generation remains too expensive. Caching the final artifact is a much smaller architectural step than projecting the complete map.
A practical implementation order
The shortest path to a reliable system is:
Store geometry with the correct SRID.
Add a GiST spatial index.
Generate one MVT with
ST_TileEnvelope,ST_AsMVTGeom, andST_AsMVT.Validate XYZ coordinates at the HTTP boundary.
Return
application/vnd.mapbox-vector-tilewith an ETag.Measure the direct query under realistic viewport traffic.
Add a short CDN TTL.
Calculate old and new affected tile coordinates for every mutation.
Store generated tiles if cache misses remain expensive.
Track required, generated, and purged versions separately.
Generate first and purge second.
Add recovery scans for dirty and unpurged tiles.
Increase the CDN TTL only after exact invalidation is tested end to end.
Introduce additional projections only for measured bottlenecks.
The PostGIS functions solve geometry encoding. The versioned cache solves a different problem: keeping an external representation synchronized with changing source data.
That distinction is the core of the design. Treat a tile as a binary response that happens to be cached, and invalidation remains an unreliable side effect. Treat it as a versioned artifact, and stale state, failed generation, and failed purging all become visible conditions the system can recover from.