Streaming Live Vehicle Positions into a Generated Map

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.

What actually needs to be live The page shell and basemap change on release and stay static. Route geometry and depot boundaries change monthly and are fetched on load. Vehicle positions change every few seconds and are the only part that needs a streaming channel. Four layers, one channel shell, basemap, style — changes on release static, immutable, cached for a year, rolled back by pointer routes, depots, service areas — changes monthly fetched once on load, revalidated, versioned with the build vehicle positions — changes every few seconds the only layer with a live channel, and the only one that needs a server

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

Polling, SSE and WebSocket compared Polling is one-directional, needs no special infrastructure and wastes requests when nothing changed. Server-sent events are one-directional, reconnect automatically with a last-event id and travel over ordinary HTTP. WebSockets are bidirectional and the right choice only when the client must send messages upstream. Three channels — pick the least capable one that works polling simplest, cacheable, works everywhere wastes a request whenever nothing changed; latency is half the interval server-sent events one-directional, auto-reconnect, plain HTTP resumes with Last-Event-ID after a drop — the default for a position feed WebSocket bidirectional — needed only if the client sends upstream costs a protocol upgrade, sticky connections and your own reconnect logic

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.

Three ways a live layer stays honest Vehicles that have not reported within the timeout are removed rather than left frozen in place. The connection state is shown in the interface, so a dropped feed is visible. Symbol opacity decreases with the age of the last report, so a degrading feed is visible before it fails entirely. A frozen dot is worse than a missing one age out after a timeout remove, do not freeze — a stale dot claims a position it does not have show the connection state "live", "reconnecting", "offline" — one line, always visible fade with report age opacity interpolated on age_s — degradation is visible before failure

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 visibilitychange as 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.