Scaling GIS Data: Querying 7,000+ Land Parcels Without a Large GeoJSON
By Aldridge Dagos, operations software engineer
The parcel map works beautifully on the sample file. Two hundred shapes, instant pan, crisp outlines, and everyone in the demo nods. Then the real county export lands, the browser eats a large GeoJSON of several hundred megabytes, and the tab goes white for eleven seconds before the fans start. Nothing crashed. The data simply arrived faster than the main thread could parse it, and a map that renders in one second on 200 parcels does not render in fifty seconds on 7,000. It stops rendering at all.
I designed the parcel side of a land acquisition system where owners, offers, and acreage all hang off geometry, and the rule I hold there is simple. The browser never receives a shape it is not about to draw.
The short version: A large GeoJSON is plain text, carries no spatial index, and has to be parsed in full on the main thread before a single polygon appears. Stop sending it. Either query by bounding box and return only the viewport, or serve pre-baked vector tiles, which is a binary format quantized to a grid and delta-encoded. PostGIS has done the tile encoding natively since version 2.4.0 through
ST_AsMVT, with parallel query support added in 2.5.0. Mapbox caps a single vector tile at 500 KB by default. For data that changes rarely, bake the whole pyramid into one PMTiles file on static storage and let the browser pull byte ranges. Seven thousand parcels then costs a few hundred kilobytes per screen instead of the whole county.
Why does a large GeoJSON choke the browser?
Three reasons stack on top of each other, and only the first one is obvious.
It is text. GeoJSON is defined in RFC 7946, published in August 2016, and every coordinate in it is a decimal number written out as characters. A parcel boundary with 400 vertices at full precision is a few kilobytes of digits before you add a single attribute. Multiply by 7,000 and you are moving a novel to draw a map.
It has no index. The file is one array. To find the shapes inside your current viewport, something has to read every feature and test it. On the server that is a query with a spatial index behind it. In the browser it is a loop over the whole array, in JavaScript, on the same thread that handles clicks.
Parsing blocks everything. JSON.parse on a large payload holds the main thread for as long as it takes. The page is not slow during that window, it is frozen. No hover, no scroll, no spinner animating, because the thread that would animate the spinner is busy building your object graph. FlatGeobuf’s own benchmark page puts the cost in perspective: reading a full dataset from GeoJSON measures about 15 times slower than the shapefile baseline, while FlatGeobuf comes in at 0.46 times that baseline.
Most parcel maps are built against a sample export, because a sample is the file that arrives first. The sample never shows you any of this.
Vector tiles vs GeoJSON: what actually changes
A vector tile is not a compressed GeoJSON. It is a different idea. Coordinates get quantized to a fixed grid inside the tile, then delta-encoded, so a boundary that needed eight decimal places of text becomes a short run of small integers. The default grid, per the Mapbox Vector Tile specification, is 4,096 screen space units across a tile.
| Format | What ships to the browser | Spatial index | Where it lives | Reach for it when |
|---|---|---|---|---|
| Raw GeoJSON | The entire layer, as text | None | Any static host | Under about 1,000 simple features, and never growing |
| On-demand vector tiles | One binary tile per screen area | The database index, server side | A database plus a tile route | Geometry or attributes change through the day |
| PMTiles | Byte ranges from one baked archive | Inside the archive | Object storage, no server | Data refreshes daily or slower and you want zero infrastructure |
| FlatGeobuf | Byte ranges from one binary file | Packed Hilbert R-tree | Object storage, no server | You need whole features back, not just pixels |
The size ceiling matters more than people expect. Mapbox limits a single vector tile to 500 KB and drops features that push past it, noting the loss in the job warnings rather than failing loudly. Mapbox has since made that ceiling configurable up to a higher band, but the default is the one you will hit first, and dropped features in a parcel map means missing land. Simplify geometry per zoom level so the tile fits, instead of discovering the cap by finding a hole in your county.
How do you send only what the viewport holds?
Bounding box first, always. The map knows its own extent, and PostGIS answers that question with an index rather than a scan. The && operator tests bounding box overlap and is the part a GiST index accelerates, so it goes first and the expensive exact test goes second.
-- Everything inside the current viewport, encoded as one vector tile.
-- The && bbox test runs against the GiST index. ST_Intersects refines it.
with bounds as (
select st_tileenvelope($1, $2, $3) as geom -- z, x, y
),
tile as (
select
p.parcel_id,
p.owner_name,
p.acres,
st_asmvtgeom(p.geom, b.geom, 4096, 64, true) as geom
from parcels p, bounds b
where p.geom && b.geom
and st_intersects(p.geom, b.geom)
)
select st_asmvt(tile, 'parcels', 4096, 'geom') from tile;
ST_AsMVT is an aggregate that returns the raw binary tile, and it has been in PostGIS since version 2.4.0, with parallel query support arriving in 2.5.0 so tile generation spreads across cores. ST_AsMVTGeom does the clipping and the transform into tile space. That fourth argument, the buffer of 64 units, is the one people leave out and then spend an afternoon on: without it, a polygon crossing a tile boundary gets a visible seam where the outline stops at the edge.
Paul Ramsey’s write-up on ST_AsMVT performance is worth reading if you want to see how much of this work moved into the database. The practical result is that a tile route becomes thin. It turns z, x, y into that query and returns the bytes.
On the client, the only trick that matters is not racing yourself. A user panning across a county fires many map moves per second, and every one of them starts a request that the next one makes irrelevant.
// Cancel the request the user already panned away from.
let inFlight = null;
map.on('moveend', async () => {
inFlight?.abort();
inFlight = new AbortController();
const b = map.getBounds();
const url = `/api/parcels?w=${b.getWest()}&s=${b.getSouth()}`
+ `&e=${b.getEast()}&n=${b.getNorth()}&z=${Math.round(map.getZoom())}`;
try {
const res = await fetch(url, { signal: inFlight.signal });
render(await res.json());
} catch (err) {
if (err.name !== 'AbortError') throw err; // a cancel is not a failure
}
});
Two more rules go with it. Do not draw parcel polygons below the zoom where they are smaller than a few pixels, because nobody can see them and every one of them still costs geometry. And cap the returned feature count, then tell the user plainly when the cap was hit, rather than silently showing part of the county. A map that quietly renders 500 of 3,000 parcels is the same failure mode as a dashboard that shows a calm morning that never happened.
When the data barely changes, bake it once
Parcel boundaries do not move. Ownership changes, assessed values change, but the shape of the land is stable for years. That makes a pre-baked pyramid the cheapest answer available, and it removes the tile server entirely.
PMTiles is a single-file archive holding the whole tile pyramid, designed to sit on object storage. The browser reads it with HTTP range requests, pulling only the bytes for the tiles it needs, which means no tile server, no database on the hot path, and a hosting bill that is storage plus egress. The v3 revision cut directory overhead to roughly a tenth of what v2 needed and added browser-side decompression, which Protomaps reports reduces vector tile size and latency by as much as 70 percent.
FlatGeobuf solves the neighbouring problem. It carries an optional static packed Hilbert R-tree index and supports the same range-request pattern, so you can pull real features with their attributes out of a remote file instead of pulling pixels. It is BSD 2-Clause licensed, which matters when the answer has to stay free.
- Data changes hourly or by user action: on-demand tiles from the database with
ST_AsMVT. - Data refreshes nightly or weekly: bake PMTiles, upload, done.
- You need the feature record, not the picture: FlatGeobuf with a bounding box read.
- Under a thousand simple shapes and staying there: plain GeoJSON is fine, and reaching for tiles is work you do not need.
Does any of this work in Leaflet?
Partly, and the honest answer shapes the choice. Leaflet draws SVG by default, which means one DOM node per parcel, and a few thousand DOM nodes is where browsers start to struggle. Switching to the canvas renderer removes the per-shape DOM cost and buys real headroom. Beyond that you are adding a plugin to teach Leaflet to speak vector tiles at all.
MapLibre GL JS reads vector tiles natively and renders through WebGL, which is the right tool when the count runs into the thousands. It came out of the December 2020 relicensing of Mapbox GL JS v2, when the community forked the last open version and continued it, and it carries a 3-Clause BSD license. No account, no key, no per-view billing, which keeps a parcel map free to operate.
The gap between a demo and production is almost never the map library. It is whether the geometry arrives already filtered.
Size for the real county, not the sample
A property portal I delivered holds 9,099 property records across a national map with filters, comps, and reports on top. The number that made it usable was never the render speed. It was how little of the dataset ever crossed the wire.
Decide the transport before you write the map. Everything after that is styling. And once the geometry is fast, the next wall is almost always the records attached to it, where the same owner arrives spelled three different ways across three different files, which is its own kind of cleanup.
Draw what is on screen. Nothing else.
Frequently asked questions
Why is my large GeoJSON so slow in the browser?
Because it is text with no index, and it has to be parsed in full before anything draws. Every coordinate is written out as decimal characters, so a few thousand parcel boundaries become hundreds of megabytes, and JSON.parse holds the main thread for the whole payload. The page freezes rather than degrading. FlatGeobuf’s published benchmark measures reading a full dataset from GeoJSON at roughly 15 times slower than the shapefile baseline.
How many features can a web map handle before I need vector tiles?
As a working rule, plain GeoJSON is fine up to roughly a thousand simple features, and Leaflet’s canvas renderer stretches that further than its default SVG path. Past a few thousand polygons you are fighting the transport, not the renderer. Land parcels hit the wall early because each boundary carries far more vertices than a point marker does.
Should I use vector tiles or a bounding box query?
They solve different halves. A bounding box query keeps the response small and current, which is what you want when attributes change through the day. Vector tiles add quantization and delta encoding on top, so the same viewport ships far fewer bytes and the client renders it through WebGL. ST_AsMVT lets you do both in one query, filtering by bounding box and returning an encoded tile.
What is the maximum size of a vector tile?
Mapbox limits a single vector tile to 500 KB by default and drops features that exceed it, recording the loss in the job warnings rather than failing the build. That ceiling is configurable to a higher band, but the default is the one most people meet first. Simplify geometry per zoom level so tiles fit under the cap, because dropped features in a parcel layer read as missing land.
Can I serve map tiles without running a tile server?
Yes. PMTiles packs the entire tile pyramid into one file on object storage and the browser fetches byte ranges over HTTP, so there is no server and no database on the request path. FlatGeobuf does the same for whole features using a packed Hilbert R-tree index. Both suit data that refreshes on a schedule rather than continuously, which describes parcel boundaries well.