One listener, 100k+ interactive elements
In short: instead of giving every one of 100k+ map elements its own event listener, the map now has exactly one — backed by a registry that instantly knows what was clicked. Faster, lighter on memory, and less code than before.
Context
The same GIS platform as the rendering case study: pipes, devices and markers rendered as 100k+ interactive entities on a map. Every one of them is clickable, hoverable and draggable — and each can belong to a different feature with its own behavior.
Problem
Originally every element on the map owned its own listener — which is
exactly what Google's own docs suggest: addListener per
marker or polyline. That default is fine at hundreds of elements and
falls apart at this scale. At best,
listeners were detached for elements outside the viewport — which meant
every pan and zoom triggered attach/detach churn over thousands of
elements, and at full scale the map provider was holding six figures of
listener closures. Interaction logic was scattered too: whoever attached
the listener decided what a click meant, so the map layer got touched by
every new feature.
The mechanism
Flip the ownership: elements don't listen — the map does, once, and a registry answers the only question that matters: what did the user actually hit?
1. A registry holds every entity, indexed twice: by id
(hash map, O(1)) and by position (sorted lat/lng arrays, binary-search
range queries).
2. The map gets exactly one listener.
3. On interaction, the listener takes the event's
lat/lng and resolves it through the registry to the concrete marker or
polyline — no scanning, no per-element handlers.
4. The resolved event goes through a per-type event bus
to the controller that owns that feature. Each marker can belong to a
different feature — and its handling lives in that feature's controller,
not in the map layer.
Before / after
| Aspect | Listener per element | One listener + registry |
|---|---|---|
| Listeners attached to the map | one per element — 100k+ at full scale | exactly one |
| Pan / zoom behavior | constant attach/detach churn as elements enter and leave the viewport | nothing to attach or detach — the registry already knows everything |
| Hit-test on click | whichever element happened to own a listener | registry lookup by lat/lng — O(1) by id, binary search by position |
| Feature coupling | click handling scattered across the codebase, map layer touched by every feature | per-type event buses route to the controller that owns the feature |
| Memory | 100k+ listener closures held by the map provider | one closure, one registry |
Why it stays simple
Shipping a new map feature no longer touches the map layer at all: register your entities, provide a controller, subscribe to your event bus. The dispatch path is one code path for every feature — there's nothing per-feature to attach, detach or leak. That's the part that surprised the team most: the fastest version was also the least code.
100k listeners isn't a scaling problem — it's an ownership problem. Move the listening to the map and the knowing to a registry, and interaction cost stops depending on how many elements exist at all.