Matthew Rynbrand
--:--
← All writing

Streaming a Live Map to Every Client

The real-time backbone behind the Layers map — a Go service that holds a tenant's entire geospatial dataset in memory and streams it to browsers over a compressed binary socket, live.

// Abstract

Atlas keeps each tenant's corpus of map features resident in memory, kept continuously fresh by MongoDB change streams, and broadcasts it to browser map clients over a permessage-deflate MessagePack WebSocket. Two ideas define it. Every feature is encoded to bytes exactly once and those bytes are spliced into every client's stream. And a non-blocking broadcast hub fans updates out to thousands of connections without a single slow client ever stalling the others, a property its test suite proves by construction.

01A map that never reloads

A map is only useful if it's current. The moment a new place, post, or marker appears, every open map should show it, with no refresh, no poll, no full re-fetch. Doing that for a dataset of over a million features, across many concurrent viewers, is a streaming problem more than a rendering one.

Atlas is the service that solves it for Layers. It sits as a read-optimized, in-memory streaming tier in front of MongoDB: on connect, a browser receives the tenant's features in chunks, and from then on a live push for every change. The database is the source of truth, but no client read ever touches it. Reads are served entirely from RAM, and the socket stays open.

Streamed in production
1M+ points
Full initial load
~5 s
Encode per feature
Stress-tested
100K clients

02Architecture at a glance

Atlas is a single Go binary. MongoDB feeds it two ways, a bulk read on boot and a live change stream thereafter, into an in-memory engine partitioned by tenant. The engine emits every change to a hub, which fans it out to that tenant's connected clients. Google Cloud Storage holds periodic binary snapshots so the process can restart in seconds instead of re-reading the database.

MongoDBlocation data
initial fetch (1M+) · live change stream
GCS snapshotshydrate / persist
Enginein-memory · tenant-partitioned
emit on change
Hubreal-time fan-out
MessagePack over WebSocket
Browser map clientsmany, per tenant
Fig 1. Lifecycle. MongoDB feeds the engine; the engine emits to the hub; the hub streams to clients. GCS snapshots let the engine rehydrate on boot.

03The in-memory engine

The engine is the datastore, and its shape is simple: a single geodata collection of partitions keyed by tenant, each holding a slice of documents, all guarded by one sync.RWMutex. A document wraps a GeoJSON feature alongside its cached encoded bytes.

It stays fresh without polling. On startup the engine hydrates from snapshots (§7); if it comes up empty, it falls back to a one-time MongoDB read of the tenant's full corpus, over a million recent, visible locations. A background goroutine then opens a MongoDB change stream, a live feed of inserts and updates, and applies each event to the right partition with UpdateLookup so the full document travels with the change. Reads never hit Mongo again; the socket does.

Trade-off. Upserting a changed feature scans its partition to find the existing entry, O(n) per write. In exchange, the read and broadcast paths are pure in-memory slice access with no query planner in the way. Atlas optimizes for the read/fan-out side, because that is where the load is.

04Encode once, send to everyone

The key design decision in Atlas is where serialization happens. Instead of marshaling a feature separately for each recipient, every document is encoded to MessagePack exactly once, at ingest, and its bytes are cached on the document (RawMsgPack). When a change is broadcast, those pre-encoded bytes are written verbatim into every client's stream.

A hand-rolled envelope encoder writes just the small response header (action, total, event, and the FeatureCollection wrapper), then appends the cached feature bytes directly after a length prefix. No per-client re-marshal, no intermediate objects. The cost of fanning a change out to N clients is one encode plus N buffer copies, not N encodes. MessagePack is what makes this splice-able: a compact, length-prefixed binary format you can concatenate without re-parsing.

Trade-off. Each document carries a second copy of itself as encoded bytes, roughly double the per-feature memory, to buy a large, constant reduction in CPU on the hot broadcast path.

05The hub: broadcasting without blocking

The hub is the centerpiece. It follows an actor model: all mutable subscription state is owned by exactly one goroutine, Hub.Run, and every other goroutine interacts with it only by sending on channels: Subscribe, Unsubscribe, Broadcast. Single ownership means there is no lock to contend on and no interleaving to reason about. The hub processes one intent at a time.

Each connection gets a buffered channel (depth 256) and a dedicated writer goroutine. Subscription is a handshake: the caller sends a request and blocks until Run has created its channel and handed it back, so the connection is registered before any broadcast can race it.

The broadcast is a non-blocking send with a drop:

hub — fan-outgo
// Hub.Run fans one event out to every subscriber of a tenant.
for _, ch := range partition.All() {
    select {
    case ch <- event:
        // delivered
    default:
        // buffer (256) is full, drop for this one slow
        // client rather than block the hub for everyone.
    }
}

If a client's buffer is full, its update is dropped instead of blocking Run. Because the hub never waits on a slow consumer, one wedged connection can't stall fan-out to the rest, and the hub can't deadlock behind a stuck writer. It trades a guarantee of delivery for a guarantee of liveness. For a live map that's the right call: a dropped frame is fixed by the next update, but a frozen hub freezes everyone.

Subscribe
Unsubscribe
Broadcast
channels in
Hub.Runsingle goroutine · owns all state
select { case ch<-e: default: DROP }
conn Abuffer 256
conn B — full→ dropped
conn Cbuffer 256
writer goroutine → WebSocket
client A
client B
client C
Fig 2. The fan-out. A full buffer (client B) is skipped, not waited on, so no client can stall the hub.

A deadlock test covers this. It subscribes a client that never reads, fires 300 events at its 256-deep buffer, and asserts that the broadcast still completes in time and that a brand-new subscriber is still serviced. Without the default drop, the 257th send would block Run forever and freeze the whole service, so the test exists to keep that from regressing. Companion tests exercise 100,000 concurrent clients and rapid subscribe/unsubscribe churn under Go's race detector.

06The wire protocol

One WebSocket per client, compressed, binary MessagePack in both directions. The client opens the socket and sends a small request; the server answers with feature frames and, from then on, unsolicited live updates.

messagesmsgpack
// client → server
{ event: "InitialLoad", chunkSize: 250 }

// server → client
{
  action:   "Create",
  total:    <feature count>,
  event:    "InitialLoad" | "RealtimeUpdate",
  document: { type: "FeatureCollection", features: [ … ] }
}

The initial load is chunked and flow-controlled by hand: the server sends a chunk of features, then waits on an acknowledgement from its own writer before preparing the next. That bounds memory and lets it reuse buffers safely under load. Once the client is caught up, realtime updates arrive unprompted as each MongoDB change flows engine → hub → socket. A keepalive ping every 5 seconds holds the connection open.

Browser → ServerWS connect /atlas/stream
resolve tenant by host · auth if private
Server ⇄ Hubsubscribe · get connection channel
▲ registered before any broadcast
Browser → Servermsgpack { InitialLoad }
chunks of 250, ack-gated
Server → BrowserFeatureCollection frames
on every MongoDB change
Server → BrowserRealtimeUpdate (unsolicited)
every 5s
Server → Browserping (keepalive)
Fig 3. Connection lifecycle, from handshake through ack-gated bulk load to unsolicited live pushes.

07Fast restarts

Because the entire working set lives in RAM, a naive restart would mean re-reading well over a million documents from MongoDB before the service could serve anyone. Atlas avoids that with snapshots: the engine periodically serializes each partition to a binary blob in Google Cloud Storage, chunked at 50,000 documents, one goroutine per partition.

On boot it does the reverse. It hydrates first, loading snapshots back into memory with a concurrency limit of 20, and only touches MongoDB if the snapshots are empty. A cold start becomes a fast parallel blob read instead of a database scan, and the primary database is spared the load. An admin endpoint can trigger a snapshot on demand.

08Multi-tenant access control

Atlas is multi-tenant, and isolation is enforced at the door. Three layers gate a connection:

The partitioning reinforces this. Because the engine and hub are split by tenant, a connection is only ever wired to its own partition's channel; no code path fans one tenant's features to another's clients.

09Scale & numbers

The performance model is features per second: because a feature is encoded once and copied to many sockets, aggregate delivered throughput scales with connection count, and the in-repo load generator measures exactly that: total features received across thousands of simultaneous connections. In production, Atlas has streamed a tenant's full set of over a million points to a fresh client in roughly five seconds.

ParameterValue
Locations streamed per tenant1,000,000+
Full initial load (production)~5 seconds
Default chunk size250 features / frame
Per-connection buffer256 events
Snapshot chunk size50,000 documents
Snapshot load concurrency20 partitions
Keepalive pingevery 5 s
Concurrency stress test100,000 clients

The honest ceiling is that Atlas is scale-up, not scale-out: state is process-local, so it runs as a single stateful replica. That keeps the design simple and strongly consistent, at the cost of a per-node memory bound and no horizontal spread. It's a fair trade for a service whose whole advantage is holding everything in one place, in memory.

10Roadmap: scaling out

The single-replica ceiling from the previous section is the most interesting thing left to solve, and the current design points straight at the answer. Because state is already partitioned by tenant, the tenant is a natural shard key: instead of one node holding everyone, a fleet of Atlas nodes can each own a disjoint subset of tenants.

Clientsmany tenants
route by tenant (consistent hash)
Routing tiertenant → node
tenant affinity — one tenant, one node
Atlas node Atenants α–γ
Atlas node Btenants δ–ζ
Atlas node Ctenants η–θ
hydrate / hand off via snapshots
GCS snapshotsany partition, any node
Fig 4. Proposed horizontal scale: shard tenants across a fleet behind a routing tier, using snapshots for cheap hand-off. Planned.

A thin routing tier would map each tenant to the node that owns it, via a consistent hash ring or a small coordination service, and pin every WebSocket upgrade for that tenant to the same node. The property that matters is that a tenant lives entirely on one node, so its fan-out stays in-process. The encode-once broadcast model carries over unchanged, with no cross-node message bus to add latency or a delivery problem.

Prior art. This is the same partition-affinity routing problem I've already solved and shipped in M.A.C.H.I.N.A, whose Smart Gateway resolves each partition to its owning node through a consistent hash ring backed by etcd, with zero-RPC migration driven by the control plane. That paper covers the routing and rebalancing design in full.

The snapshot machinery from §7 is what makes this cheap to operate. Because any tenant's partition can be rehydrated on any node straight from GCS, moving a tenant, whether to rebalance a hot node or fail over a lost one, is a matter of re-pointing new connections and hydrating the target, not a bespoke migration. Horizontal scale and high availability then fall out of the same mechanism: the per-node memory bound disappears, and no single node stays a single point of failure.

11Stack

LayerTechnologyRole
LanguageGo 1.25Concurrency-first backend
Transportgorilla/websocket · gorilla/muxCompressed WebSocket + routing
SerializationMessagePack · gobWire frames · snapshots
Source of truthMongoDB (change streams)Live feed, secondary reads
SnapshotsGoogle Cloud StorageFast cold starts
ObservabilityPrometheus · Sentry · slog · pprofMetrics, errors, profiles
DeploymentDocker · KubernetesSingle stateful replica
← All writing