100k+ map entities, smooth at 60fps
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".