Part of the Static vs Dynamic Map Export Methods guide.
Operative rule: stream only the layer that actually moves — a live feed for vehicle positions does not justify making the basemap, the boundaries or the page shell dynamic.
Split by Rate of Change, Not by Technology
A vehicle-tracking dashboard looks like a live product, but most of it is not live at all. The page shell changes on release. The basemap changes when the style does. Route geometry and depot boundaries change monthly. Only the positions change by the second — and only they need a channel.
Keeping that split explicit preserves everything the static pipeline gives you: aggressive caching, instant rollback, no server to keep up. The live layer becomes a small, well-bounded exception rather than a reason to rebuild the architecture.
Production-Ready Implementation
const FEED = "/live/positions"; // text/event-stream
const SOURCE = "vehicles";
const MAX_AGE_MS = 90_000; // drop a vehicle after 90 s of silence
const positions = new Map(); // id → { lon, lat, bearing, ts }
let source = null;
let retryDelay = 1000;
function toFeatureCollection() {
const now = Date.now();
const features = [];
for (const [id, p] of positions) {
if (now - p.ts > MAX_AGE_MS) { positions.delete(id); continue; }
features.push({
type: "Feature",
id,
geometry: { type: "Point", coordinates: [p.lon, p.lat] },
properties: { id, bearing: p.bearing, age_s: Math.round((now - p.ts) / 1000) },
});
}
return { type: "FeatureCollection", features };
}
let queued = false;
function scheduleRepaint(map) {
if (queued) return; // coalesce a burst into one frame
queued = true;
requestAnimationFrame(() => {
queued = false;
map.getSource(SOURCE)?.setData(toFeatureCollection());
});
}
export function connect(map) {
source = new EventSource(FEED);
source.addEventListener("position", (event) => {
const batch = JSON.parse(event.data); // an array, not one vehicle
for (const p of batch) {
if (!Number.isFinite(p.lon) || Math.abs(p.lat) > 85.06) continue;
positions.set(p.id, { lon: p.lon, lat: p.lat, bearing: p.bearing ?? 0,
ts: Date.parse(p.ts) || Date.now() });
}
retryDelay = 1000; // healthy: reset backoff
scheduleRepaint(map);
});
source.addEventListener("error", () => {
source.close();
setTimeout(() => connect(map), retryDelay);
retryDelay = Math.min(retryDelay * 2, 30_000); // exponential backoff
});
// Sweep expired vehicles even when the feed goes quiet.
setInterval(() => scheduleRepaint(map), 15_000);
}
The two mechanisms that keep this stable under load are batching and coalescing. The server sends arrays rather than one message per vehicle, and the client repaints once per animation frame no matter how many messages arrived — so a fleet of two thousand vehicles reporting every five seconds produces sixty repaints a minute, not two thousand.
Choosing the Channel
Making the Live Layer Honest
A moving dot implies currency, so the interface has to be explicit when that implication stops being true. Three behaviours cover it: age out stale vehicles rather than leaving them frozen on screen, show the connection state somewhere visible, and fade symbols as their last report ages so a degrading feed is visible before it is broken.
Keeping the live layer this narrowly scoped is what lets the rest of the dashboard keep the properties a static pipeline provides — immutable artifacts, instant rollback and caching that costs nothing to operate.
Verification Steps
- Disconnect the network and confirm vehicles age out and the state indicator changes.
- Restore it and confirm reconnection happens with backoff rather than a tight retry loop.
- Send a batch of a thousand updates and confirm exactly one repaint occurs.
- Send a malformed coordinate and confirm it is skipped rather than corrupting the layer.
- Confirm the basemap and boundary layers make no requests while positions are streaming.
Common Errors & Fixes
Frame rate collapses as the fleet grows
Each message triggers its own setData. Coalesce into one repaint per animation frame, as above.
Vehicles freeze in place after a network blip
There is no age-out sweep. Run it on a timer, not only on message arrival, or a silent feed leaves the last frame on screen forever.
The feed reconnects in a tight loop against a failing server
No backoff. Double the delay up to a ceiling, and reset it only after a successful message.
The whole page becomes dynamic to support one layer
The live requirement leaked into the build. Keep the shell and basemap on the static pipeline and give only the moving layer a channel.
Interpolating Between Reports
Positions that arrive every few seconds and are drawn as they arrive produce a map where vehicles jump. The jump is accurate — it is exactly what was reported — and it reads as unreliable, because readers expect movement to be continuous.
Interpolating between the last two reports smooths it without inventing information: each vehicle animates from where it was to where it now is, over roughly the reporting interval. The result is a map that feels live rather than stroboscopic, and it costs a per-frame update of a position that is already in memory.
Two cautions apply. Do not extrapolate past the latest report — a vehicle that stopped reporting must stop moving, or the map is showing a position nobody claimed. And keep the animation duration close to the actual interval: animating a five-second gap over one second produces a rush-then-wait motion that is more distracting than the jump it replaced.
Bearing deserves the same treatment. A symbol that snaps between headings looks mechanical; interpolating the rotation, taking the shorter way round the circle, makes the same data read as movement. It is a small detail that does a disproportionate amount of work in how trustworthy the layer feels.
Backpressure and the Slow Reader
A live channel connects a server that can produce quickly to a client that may not be able to keep up. A laptop in a background tab, a phone on a poor connection, a machine with the tab throttled — each will receive updates more slowly than they are produced.
The client-side protections are the ones already described: batching, coalescing repaints to one per frame, and dropping intermediate states rather than queuing them. Because only the latest position of each vehicle matters, discarding intermediate updates is not data loss in any meaningful sense — nobody needs to see where a vehicle was two seconds ago on its way to where it is now.
The server side needs a matching decision. Sending each client every update as fast as it is produced is wasteful when the map redraws sixty times a second at most; sending a consolidated snapshot on a fixed cadence — every two or three seconds — is simpler, smaller and produces exactly the same visible result. It also bounds the server’s work per client, which is what keeps a fleet dashboard viable as the number of viewers grows.
Gotchas & Edge Cases
- Browsers limit concurrent connections per origin, and a long-lived stream occupies one of them; on HTTP/1.1 several open dashboards can starve each other of ordinary requests.
- A background tab has its timers throttled, so a client-side age-out sweep may not run — re-evaluate ages on
visibilitychangeas well as on a timer. - Proxies and load balancers frequently buffer responses, which delays events until the buffer fills; disabling buffering for the stream endpoint is usually a one-line configuration and is easy to forget.
- Reconnecting with a last-event id only helps if the server honours it; otherwise a reconnect silently starts from the present and any gap is invisible.
- A vehicle removed for staleness and then reporting again should reappear rather than being treated as new — keep identity stable so its history and any open popup survive the gap.
Related
- Static vs Dynamic Map Export Methods — the parent guide covering the bind-time decision
- Webhook-Triggered Map Updates for Geo-Dashboards — the server side of pushing changes outward
- Rendering a Million Points with PyDeck ScatterplotLayer — what to do when the live layer outgrows GeoJSON
- Publishing a Data Freshness Badge on a Geo-Dashboard — the same honesty applied to the static layers