← Back to results

100k+ map entities, smooth at 60fps

Case study · Swiss water-infrastructure GIS platform · Angular

In short: the map stuttered exactly when users were dragging and editing, because every small change made the app recompute everything. After this work the app only recomputes what actually changed — interactions became instant, and no rewrite was needed.

Context

A GIS platform for managing water infrastructure: pipes, devices and data points rendered as interactive entities on a map. Production networks hold 100k+ entities; the numbers below were benchmarked on a ~500k-entity dataset. Everything is held and rendered client-side — Angular, with Google / Leaflet / Baidu behind a provider-agnostic adapter.

Problem

Every structural change — dragging a marker, attaching a pipe, deleting a device — triggered a full rebuild of the spatial indices: clear everything, iterate all entities, re-insert, re-sort. At this scale that's hundreds of milliseconds per drag event, and the app stuttered exactly when the user was mid-interaction. On top of that sat silent correctness bugs: indices leaking on delete, events lost in a debounce, a splice(-1) quietly corrupting the sorted structure.

Approach

No rewrite of the rendering layer — the wins came from making the work proportional to the change instead of the dataset: incremental index updates instead of rebuilds, range queries instead of full scans, batches instead of per-entity redraws, and deleting work from hot paths that never needed to be there.

What the user feels

Scenario Before After Gain
Dragging a marker full rebuild, visible stutter incremental, smooth 60fps ~99.99%
First load ~300–600 ms ~100–200 ms 2–3×
Pan / zoom on a large network visibility pass over all entities only entities in frame ~95–99%
Saving edits refetch + rebuild of the whole network reconcile changed entities only ~99.99%
Mass delete O(M·N log N) + leaking indices O(M·log N), clean indices ~99.99%
Click a marker (hit-test) answered after a timeout, against stale indices immediate, indices always in sync latency → 0

Key optimizations — the engineering behind it

Optimization Before After Time cut
Incremental spatial-index updates on drag every drag → full index rebuild, O(N log N), hundreds of ms in-place index move, O(log N), ~µs ~99.99%
Viewport culling with UI occlusion visibility recomputed for every entity, O(N) range query over the spatial index, bounds shrunk by open sidebars/popups — only what you can actually see ~95–99%
Initial index build hand-rolled heapsort, run twice over the full set native TimSort on new entries + merge with the sorted rest ~50–66%
Save in edit mode full refetch + rebuild of the entire network after every save reconcile only the changed entities from the server response ~99.99%
Visibility lookups consumers built a fresh Map and scanned it per check, O(N) direct O(1) lookup on the registry ~99.99%
Batched entity updates debounce dropped all but the last event — one redraw per entity buffered batches — one redraw per batch ~95–99%
Conflict repacking O(N) repack after every structure event updated directly at the point of change — repack eliminated ~100%
Hot-path hygiene JSON.stringify logging per edit, .find()/.includes() in loops, DOM cursor writes on every pointermove logs removed, Set/Map lookups, cached cursor state ~40–95%

Correctness, not just speed

Performance work at this scale keeps tripping over correctness bugs, and several fixes were exactly that: delete now actually cleans every index (no more memory growing with each removal), a missing guard no longer lets splice(-1) silently corrupt the sorted structure, buffered batching stopped a debounce from dropping all but the last update, and layer initialization is coordinated by signals instead of timing guesses.

Tests

The same codebase got its test suite cut from minutes to seconds — transpile-only test transforms, running only affected projects, and ~70 empty "should create" specs deleted — while real coverage of the core spatial domain went up by over a thousand lines of meaningful tests.

The pattern behind every row above: make the work proportional to the change, not the dataset. O(N) on every interaction is invisible at 1k entities and fatal at 500k — the fix is almost never "render faster", it's "stop recomputing the world".

Related: one listener for 100k+ interactive elements