Why your web map is slow — and what actually fixes it
A million-feature layer that crawls in the browser is rarely a hardware problem. Here's what really makes web maps slow — whole-file downloads, WMS round trips, heavy tiles — and how vector tiles and generalisation fix it.
The layer works perfectly in QGIS. You publish it, open the web map, and the country view spins for fifteen seconds before painting a grey rectangle. Zoom in and everything is fine. Zoom out and the tab eats two gigabytes of RAM and dies.
This is the most common disappointment in web GIS, and it is almost never about the server being too small. It's about how much data crosses the wire and how much survives in the browser's memory once it gets there — two numbers most people never measure, because desktop GIS never made them measure.
Here is what actually determines whether a large dataset is usable on the web, in the order the bottlenecks appear.
The short answer#
A web map is slow for one of four reasons, and each has a different fix:
- Too many bytes over the wire. The client is downloading the whole dataset, or a tile far heavier than it should be. Fix: serve tiles, and generalise what the low zooms contain.
- Too many features parsed in the browser. Every feature that arrives becomes a JavaScript object that has to be decoded, styled, hit-tested and kept. Fix: reduce the feature count per tile, not just the byte count.
- Too much work per request on the server. A zoomed-out query touching every row of a multi-million-row table will time out no matter how the tile is compressed. Fix: pre-built overviews, and a spatial index that is actually used.
- The wrong rendering model for the job. Some formats can't be fast at this size, however carefully you tune them.
Most "slow map" tickets are the first two wearing the disguise of the fourth.
Features are the wrong unit — count vertices#
"How many features can a web map handle?" is the question everyone asks, and it has no useful answer. Two hundred thousand bus stops and two hundred thousand coastline polygons are not the same workload; they aren't even the same order of magnitude.
The unit that predicts performance is the vertex — the coordinate pair. A point is one. A municipal boundary is often tens of thousands. A single detailed nature-reserve multipolygon can carry more vertices than a hundred thousand address points combined.
So before blaming the platform, get two numbers for the layer: the feature count and the total vertex count, and divide to get vertices per feature. In PostGIS:
SELECT count(*) AS features,
sum(ST_NPoints(geom)) AS vertices,
sum(ST_NPoints(geom)) / count(*) AS verts_per_feature
FROM your_layer;Under roughly 10 vertices per feature you have points or simple boxes, and feature count is what matters. Over roughly 200 you have outline monsters — coastlines, Natura 2000 sites, administrative boundaries — and vertex count dominates everything, including the parts you can't see on screen.
For scale: a European cycle-route dataset we use as a stress case holds 115 000 route relations and 41 million vertices. Nothing about that layer is solvable by "using a faster server".
Three ways geodata reaches a browser#
Ship the file#
GeoJSON in the page — or a shapefile converted to GeoJSON on the way out. The browser downloads the entire dataset before the first pixel appears, parses all of it, and holds all of it, whether or not it's on screen.
This is genuinely fine up to a few thousand simple features, and it stays seductive because it's easy. But the cost is unbounded: a 300 MB GeoJSON is a 300 MB download and roughly a gigabyte of live objects, at every zoom level, including the one where the whole dataset is four pixels wide.
Let the server draw it (WMS)#
The server renders pictures and sends images. Client cost is constant no matter how big the dataset is, which is exactly why WMS survived twenty-five years of much bigger data.
What it costs you is everything that happens after the picture is drawn. Styling lives on the server, so changing a colour is a round trip, or an admin request. There is no client-side filtering, no hover, no click-to-inspect without a second GetFeatureInfo call per click. Labels can't reflow. And a plain WMS renders on demand — every pan is fresh work for the server, which is why WMTS (pre-rendered, cached tiles) exists and why it is so much faster, at the price of fixed styling and a cache to invalidate.
Cut it into vector tiles#
The geometry itself is cut into a tile pyramid and delivered as compact binary tiles (MVT). The client gets only the tiles covering the current viewport at the current zoom, and draws them itself.
That last part is the point. Styling is client-side, so a colour change is instant with no re-fetch. Features are real objects, so hover, click, popups and client-side filtering work on data the browser already has — a dashboard filter can drop features out of a map with no request at all. Labels reflow. Tiles cache like images.
The cost moves to two new places: someone has to generate the tiles, and a tile at a low zoom can be enormous. Which is the whole ball game.
| Whole file (GeoJSON) | WMS / WMTS raster | Vector tiles (MVT) | |
|---|---|---|---|
| Data transferred | The entire dataset | One image per tile/view | Only visible tiles |
| Client cost at scale | Grows with dataset size | Constant | Grows with tile weight |
| Restyle | Instant (all in memory) | Server round trip | Instant |
| Click / hover / popups | Yes | GetFeatureInfo per click | Yes |
| Client-side filtering | Yes | No | Yes |
| Practical ceiling | A few thousand features | Very high | Very high, with generalisation |
| Main failure mode | Download + memory | Server render time, no interactivity | Heavy low-zoom tiles |
The zoomed-out tile is where maps die#
Zoom level 14 is easy: a tile covers a few streets, so it contains a few features whatever the layer holds. Zoom level 4 is the problem — one tile covers a subcontinent, and a naive implementation puts the entire dataset in it.
That single tile has to be produced under a hard server time budget (tile queries that run long are cut off, and a cut-off tile is a blank tile), compressed, sent, parsed and drawn. Meanwhile every feature in it is sub-pixel: at that scale a building footprint is smaller than the dot rendering it. You are paying to transmit detail that is physically impossible to see.
That's the trade the whole discipline turns on. What a map shows when zoomed out should not be the same data it shows when zoomed in. It should be a truthful summary, and it should be small.
Generalisation: making the low zooms honest and small#
There are only four things you can take away from a tile, and good platforms use all of them, at different zooms.
Fewer vertices. Douglas–Peucker or similar, with the tolerance sized to about one pixel at that zoom. Every feature stays, only the shape gets coarser — and below a pixel, coarser is invisible. This is the right treatment for outline-heavy polygons.
There's a catch that catches most people: simplification can't remove the endpoints of parts. On our 115 000-route cycle network, routes averaged around 26 disjoint parts each (one per member way), so the part endpoints alone dominated tile weight and simplification barely bit. Stitching the parts together before simplifying took a dense zoom-5 tile from 15.6 MB to 0.4 MB in our own measurements. If your simplification "isn't doing anything", look at the part count before the vertex count.
Fewer features. Below the zoom where the median feature spans a pixel, drawing every feature is not just expensive, it's misleading — you get a solid smear that hides the density it's supposedly showing. Bin the features into cells and carry a count and a few aggregates instead. Done right, the low zooms trace the dataset's actual silhouette and tell you more than the raw layer would.
Fewer attributes. The one people forget. At mid zooms, tile weight is often dominated by attributes, not geometry — a full source schema repeated across every feature. Dropping to a handful of low-cardinality columns for the zooms where nobody is clicking measured about 7× smaller tiles in our own pipeline, with identical geometry.
A different summary entirely. For uniform categorical grids — land-cover cells, degree-of-urbanisation classes, anything that's millions of identical squares carrying one class — neither of the above works: density counts are flat (every cell is one feature) and slimming attributes still leaves 4.7 million polygons. The only honest summary is the majority class per coarser cell, which is the vector equivalent of raster majority resampling. It is also a judgement call about meaning, not a heuristic: "most common land-cover class in this square kilometre" is true and useful; "most common parcel owner" is neither. Aggregation like this should always be opt-in.
Generalisation is a display decision. Your source data should never be modified to make a map fast — the overviews are additional artefacts, and every export, download and analysis still runs against the full-resolution original.
The browser is the other half of the problem#
Byte size is the number everyone optimises, and it's only half the story. A vector tile that compresses to 500 KB doesn't stay 500 KB in memory. Parsed, each feature becomes a geometry object, coordinate arrays, per-layer render instructions and hit-detection copies.
We measured roughly 3.5 KB retained per feature in the browser for a dense categorical grid. That sounds trivial until you do the multiplication: a settled viewport holding twenty tiles of about 98 000 features each is ~3.4 GB of live objects — brushing the point where the browser kills the tab. The tile was fine. The server was fine. The map died anyway.
This is why a serious viewer needs client-side memory discipline as well — evicting tiles that have left the viewport, capping the bytes held, and applying backpressure so parsing can't run ahead of rendering. And it's why the size of a zoomed-out tile has to be capped at generation time, in features, not only in bytes.
A checklist that works on any platform#
- Measure vertices, not just features — and check the parts-per-feature count on line and multipolygon layers.
- Store data in a projected, metre-based CRS with a spatial index. Distances in degrees aren't isotropic, and generalisation tolerances in degrees are meaningless. (Which system to pick is its own article.)
- Serve tiles, not files, for anything above a few thousand features.
- Test the worst tile first. Zoom all the way out, open the network tab, and look at the size and time of the heaviest tile. That single number predicts almost everything.
- Slim attributes at low zoom. Nobody clicks a feature that's a quarter of a pixel wide.
- Cap the feature count per tile, not just bytes — that's the number the browser's memory tracks.
- Verify on a mid-range laptop, not the workstation you built the data on. Your users are on the former.
- Never measure areas and distances on the display projection. That's a correctness bug, not a performance one, and it's a much more expensive mistake.
How OrbGIS handles it#
We built the ingest pipeline around exactly these failure modes, because the datasets our users bring — national land cover, all buildings in a municipality, Natura 2000, complete route networks — break naive implementations on the first upload.
- Every upload becomes a real hosted layer: typed columns, a spatial index, a computed extent, and MVT vector tiles from our own tile server. No client-side GeoJSON path exists for feature layers, so there's no size at which the map quietly falls back to something that can't cope.
- Large layers get a level-of-detail pyramid automatically — built at ingest, past roughly 100 000 features or very heavy geometry (about 5 million vertices), with no setting to find and no decision to make. Points, polygons and line networks each get the treatment that suits them, including the part-stitching described above for networks.
- Zoom-band selection is server-side. The tile server picks which pre-built band answers each request, so the correct data arrives whatever client is asking, and a band can be rebuilt without shipping any client change.
- Tile budgets are derived from measurement, not formula — including the browser-memory ceiling above, which is why the handover to full-resolution data happens where a settled viewport stays comfortably inside what a laptop can hold.
- The dataset page shows you what was built. A Rendering pyramid card lists the bands actually in use, and offers the categorical Mode aggregation opt-in for class grids. If an overview build ever fails, the layer keeps serving from source and says so, rather than silently rendering blank tiles.
- Dashboards filter client-side. Filters, chart clicks and table selections drop features out of the map using tiles the browser already holds — no re-query, no reload.
- Edits stay live. Changes saved from the QGIS plugin appear immediately at detailed zooms, and queue an overview refresh so the zoomed-out bands catch up within a few minutes.
None of this is exotic. It's the standard set of techniques the tile-serving world has converged on — the difference is whether you have to implement them yourself for every dataset, or whether they happen when you drop the file.
Frequently asked questions#
Why is my web map slow when I zoom out, but fast when I zoom in?#
Because a zoomed-out tile covers far more ground. At high zoom, a tile contains a handful of features no matter how big the layer is; at low zoom, a naive implementation puts the entire dataset into a single tile. That one tile has to be queried, compressed, transferred, parsed and drawn — usually while every feature in it is smaller than a pixel. The fix is pre-built overviews (a level-of-detail pyramid) that serve a lighter, generalised summary at low zooms.
How many features can a web map handle?#
There's no fixed number, because features aren't the limiting unit — vertices and bytes per tile are. Two hundred thousand points behave completely differently from two hundred thousand detailed polygons. As a rough guide: a few thousand features is fine as a plain GeoJSON in the page; beyond that you want vector tiles; past roughly 100 000 features, or a few million vertices, you want generalised overviews for the low zooms. With those in place, tens of millions of features are routine.
Vector tiles or WMS — which should I use?#
WMS or WMTS if the map is a picture: fixed cartography, no interaction, and possibly a rendering engine you can't replace. Vector tiles for anything interactive — client-side styling, instant restyling, hover, popups, and filtering without a server round trip. Vector tiles also scale better on the client, because only the visible tiles are ever transferred, but they need generalised low zooms to stay fast.
Is GeoJSON bad for large datasets?#
Not bad — just unbounded. GeoJSON is verbose text with no spatial index and no tiling, so the client downloads and parses all of it before showing anything, and holds all of it afterwards. That's fine for a few thousand simple features. For storage and exchange of large data, a binary format like GeoPackage, FlatGeobuf or GeoParquet is far more efficient; for display of large data, use tiles regardless of the source format.
What is level of detail (LOD) or generalisation in a web map?#
Serving deliberately simplified or aggregated versions of a dataset at zoom levels where full detail is invisible. In practice it means fewer vertices per feature, fewer features (density binning or thinning), fewer attribute columns, or a genuine summary such as majority class per cell — chosen per zoom band and swapped in automatically as you zoom. Done well it is both faster and more readable, because a smear of sub-pixel polygons communicates nothing.
Do I need to simplify my data before uploading it?#
You shouldn't have to, and mostly you shouldn't want to: simplifying the source is lossy and irreversible, and you'll need the full geometry for analysis, export and printing. Generalisation belongs in the rendering pipeline, as extra artefacts derived from the untouched original. In OrbGIS that happens automatically at ingest for large layers.
Does generalisation change my data?#
No. Overview bands are additional tables built alongside the source, used only to answer tile requests at low zooms. Exports, the Data Editor, QGIS round trips, attribute queries and print layouts all read the full-resolution original. Zoom in past the handover zoom and you're looking at exact geometry again.
Why does my map use so much memory or crash the tab?#
Because parsed vector features cost far more in memory than they do on the wire — geometry objects, coordinate arrays, render instructions and hit-detection structures, in our measurements around 3.5 KB per feature. Twenty heavy tiles on screen can therefore hold gigabytes of live objects even when each tile downloaded in well under a second. Capping the feature count per tile, and evicting tiles that leave the viewport, is what keeps a big layer inside a browser's budget.
Does the coordinate system affect web map performance?#
Indirectly, and it matters more for correctness. Generalisation tolerances and density cells need isotropic metre units, so data stored in a projected metre-based CRS generalises properly while data in degrees does not. Separately, if the map's projection matches the layer's, tiles can be produced natively in that projection with no client-side reprojection at all — see our guide to Nordic coordinate systems.
Try it with your heaviest layer#
The honest test of any web GIS is not the demo dataset — it's the layer that has embarrassed you before. Take the biggest one you have, upload it to OrbGIS, and zoom all the way out. Everything above happens on the way in, without a setting to configure. If you'd like a hand with a particularly awkward dataset, get in touch — we like the difficult ones.
Get new articles by email
One email when we publish something new — nothing else.