A moving viewport is a set difference: notes on GeoMQTT
Most real-time geospatial systems start with the same shape: writers push points into Redis GEO sets, readers want those points on a map…
A moving viewport is a set difference: notes on GeoMQTT

Most real-time geospatial systems start with the same shape: writers push points into Redis GEO sets, readers want those points on a map, and somewhere in between you write a websocket layer that broadcasts everything to everyone and prays the client filters.
That works until the viewport matters. Once you have more than a few thousand moving things and clients that only see a piece of the map at a time, broadcast fanout becomes the bottleneck — not because Redis is slow, but because you are sending data to clients that were never going to render it.
I built GeoMQTT as the cleanest answer I could shape for that problem. The whole thing fits in one observation:
A moving viewport is a set difference. As you pan, some tiles enter the view and others leave it. If the routing key for live updates is the slippy-map tile, a client just subscribes to entering tiles and unsubscribes from leaving ones. The fanout matches the viewport.
That is the primitive. The rest of this post is what falls out of taking it seriously.
The topic tree
The topic shape is:
geo/<set>/<z>/<x>/<y>
<set> is the Redis GEO set name (vehicles, iss, players). <z>/<x>/<y> is standard slippy-map tile coordinates. A subscriber to geo/vehicles/12/2156/1432 is asking for "the events on this one tile, of this set, at zoom 12".
Wildcards are MQTT-native, so geo/vehicles/12/+/+ ("everything at zoom 12") works without any server-side help — the broker handles it. But in practice, the client computes the visible tile set from its viewport and subscribes to the explicit list, because that is what gives you cost that scales with what you are showing, not with what exists.
What lives in the binary
GeoMQTT is one Rust binary that hosts four listeners:
- RESP on
:6380— Redis-compatible proxy. Forwards every command to upstream Redis. InterceptsGEOADD,ZREM,HSET,HDEL,DELonobj:*keys to trigger MQTT fanout. - MQTT/TCP on
:1883and MQTT/WebSocket on:8083— embedded broker, hand-rolled onmqttbytes. QoS 0, clean session. Browsers connect directly over WS — no extra bridge. - HTTP on
:8080— GeoJSON for non-live callers (/tiles/<set>/<z>/<x>/<y>,/viewport/<set>?bbox=…,/objects/<obid>), plus/healthz,/config, and/status.
Writers can use any Redis client. Readers can use any MQTT client. Existing tooling stays existing.
Snapshot burst on subscribe
The hard part of any pub/sub-on-state system is the join: a new subscriber needs the current tile contents, not just future updates. If you only stream future events, the client renders an empty tile until something moves.
GeoMQTT handles this per session. On SUBSCRIBE, the broker runs a GEOSEARCH against the relevant tile bbox and sends the result as a burst of add events to that one session, then transitions to live stream. No global flush, no replay log, no "warm cache" tier. Each subscriber gets a consistent join, entirely on a per-session path.
This is what makes the viewport-as-set-difference idea actually usable. As you pan, every newly-entering tile self-hydrates the moment you subscribe to it.
Cross-node fanout
A single node holds in-process subscription state and does fanout locally. To scale horizontally, GeoMQTT rides on Redis pub/sub: every published tile event is also republished on a Redis channel with a node-id envelope, so each node ignores its own echoes. Other nodes pick up the message, look up local subscribers, deliver.
Horizontal scale costs one Redis subscription per node and zero coordination protocol. No raft, no gossip. Redis was already in the stack; it stays the source of truth and the fan-in spine.
Metrics: atomics, not allocators
/status returns Prometheus text format. Every counter is an AtomicU64::fetch_add(Relaxed) at the hot path. Rendering a scrape walks the atomics and does one /proc/self/status read for memory. No background ticker. No allocator hooks. No Redis round-trips on a scrape.
This is a small thing, but it is the kind of small thing that decides whether you can scrape the metrics endpoint at high frequency without observable cost. You can.
The endpoint sits on :8080 next to /healthz, /config, /tiles, /viewport, and /objects — deliberately not on the conventional /metrics path. Keeping the surface cohesive on one port mattered more than honoring the convention. Prometheus scrapers configure the path; the deployment topology stays simpler. Tradeoff stated, not hidden.
What I deliberately did not do
- No QoS 1 / 2. The semantics of moving-things-on-a-map do not reward delivery guarantees; if you missed a
moveevent, the nextmoveevent corrects you. QoS 0 keeps the broker small. - No retained messages on tile topics. The snapshot-on-subscribe burst replaces them and is more accurate (Redis is the truth, not the last MQTT message).
- No object protocol.
obj:<id>hashes are arbitrary KV. Clients render what they want from the attributes they get.GEOMQTT_ENRICH_ATTRSlets you choose which keys ride in tile payloads.
Each of these is an “out” we kept open, not a feature gap.
Five clients, same protocol
- TypeScript core — Node and browser. MQTT transport, tile math, viewport-diff subscribe loop, state map.
- Leaflet adapter —
L.LayerGroup. Wiresmoveend/zoomendto the core client. - MapLibre / Mapbox GL adapter — keeps a GeoJSON source fed from current state, debounced via
updateThrottleMs. - Unity UPM package — pure C#
GeomqttClientplusGeomqttWorld3DMonoBehaviour, projecting lat/lng → local ENU meters for 3D world-anchored scenes. - Unreal plugin (UE 5.3+) — built-in MQTT v3.1.1 codec over UE’s
WebSocketsmodule, no third-party plugin required.UGeomqttClient,AGeomqttMarkerSpawner,UGeomqttSubsystemfor Blueprint access.
The Unity and Unreal clients are the bridge to the gaming side. The same primitive that lets a fleet ops dashboard render a city’s worth of vehicles also lets a multiplayer game render the players in a region — at the protocol level both are just points in a GEO set with a tile-keyed routing key.
The live demo
A public deployment tracks the ISS in real time at openfantasymap.github.io/geomqtt. The bottom-left panel shows the active MQTT subscription set as you pan, plus a rolling sub/unsub log — so you can watch the tile-keyed protocol in action against actual coordinates.
It is also the smallest possible useful example: a tiny sidecar polls api.open-notify.org every five seconds and feeds the position into GeoMQTT via a single GEOADD. Everything else is the protocol doing its job.
Where this goes next
GeoMQTT is open infrastructure. The world-state engine sitting on top of it, GaiaWM, is what I will be presenting at GDC in June 2026, and the gaming side of the story will get its own post.
What I would like back: if you are running real-time geospatial in production — mobility, fleet ops, IoT, simulation, multiplayer — try the demo, point a client at it, tell me where the abstractions break for your shape. The cleanest pull requests usually start as “this did not fit my use case”.
Repo: github.com/openfantasymap/geomqtt Demo: openfantasymap.github.io/geomqtt
Apache-2.0 / MIT dual-licensed.
메타데이터
- post_id
- 4d198a884bbc
- slug
- a-moving-viewport-is-a-set-difference-notes-on-geomqtt-4d198a884bbc
- url
- https://medium.com/openfantasymap/a-moving-viewport-is-a-set-difference-notes-on-geomqtt-4d198a884bbc
- canonical_url
- https://medium.com/openfantasymap/a-moving-viewport-is-a-set-difference-notes-on-geomqtt-4d198a884bbc
- author_url
- https://medium.com/@ingmmo
- status
- ok
- fetched_at
- 2026-06-11 12:34:08