10 min read

Real-Time Data Dashboards

Data arrives faster than eyes can read and more densely than screens can show. Design the pipeline around those two limits.


The story

A logistics control room watches a live dashboard: 40 tiles of fleet metrics, a map, and a chart of GPS pings per second. It's fed by one WebSocket that delivers about 2,000 messages a second at peak.

The first version does the obvious thing: every message updates React state.

js
socket.onmessage = (e) => setPings((p) => [...p, JSON.parse(e.data)])

In the demo, with test traffic, it's smooth. In the control room:

  • CPU sits at 100%. 2,000 state updates a second means up to 2,000 render passes a second, far more than the 60 frames a screen can show.
  • The chart lags further and further behind reality, because rendering can't keep up with arrivals and the backlog grows.
  • Memory climbs all shift. Every ping is kept forever as a JavaScript object. After about eight hours, the tab crashes with "Aw, Snap! Out of memory".
  • Switching back to the dashboard tab after a coffee break freezes it for several seconds while it processes everything that arrived while it was hidden.

None of these are bugs in the chart library. The pipeline was designed as if data arrived at human speed. This chapter is about designing it for the speed it actually arrives at.


Part 1: The two limits

Two facts set the design of every real-time dashboard:

  1. Screens update at most 60–120 times a second, and people read numbers far slower than that. Anything you compute more often than you can show is wasted.
  2. A chart has a fixed number of pixels. A 1,000-pixel-wide chart can't show 1,000,000 points. At most it can show what's in each of its 1,000 columns.

So the pipeline's job is to turn a fast, unbounded stream into a slow, bounded picture:

Each stage reduces the volume. The common mistake, as in the story, is connecting the first box straight to the last.


Part 2: Ingest

Choosing the transport

TransportDirectionGood forNotes
WebSocketBoth waysHigh-rate streams, client subscriptionsYou handle reconnection and heartbeats
Server-Sent EventsServer → clientUpdates, notifications, AI token streamsPlain HTTP, reconnects automatically, text only
PollingClient pullsData that changes every few seconds or minutesSimplest. Often good enough for "real-time" business dashboards.

The cost of parsing

At 2,000 messages a second, even JSON.parse on small messages adds up, and each parsed object is a new allocation for the garbage collector. Three ways to cut it:

  • Batch on the server. Send one message every 50–100 ms containing everything since the last one. 2,000 tiny messages become 10–20 bigger ones: far less per-message overhead on both ends.
  • Send binary. Protocol Buffers, MessagePack or a custom layout decoded straight into typed arrays avoid creating an object per point.
  • Parse in a worker. Let the worker own the socket, parse and aggregate, and hand the main thread small, ready-to-draw results (Chapter 3).

Part 3: Buffer, and render once per frame

Never turn each message into a render. Collect messages in a plain mutable structure, and flush to the UI once per frame:

js
const pending = []
let scheduled = false

socket.onmessage = (e) => {
  pending.push(decode(e.data))          // cheap: no React, no DOM
  if (!scheduled) {
    scheduled = true
    requestAnimationFrame(flush)
  }
}

function flush() {
  scheduled = false
  const batch = pending.splice(0)       // everything since the last frame
  store.apply(batch)                    // update the data model once
  chart.draw()                          // draw once
}

Two refinements:

  • Numbers people read (counters, KPIs) update at 4–10 Hz, not 60. A number changing 60 times a second is unreadable, and every update costs layout. Throttle those tiles separately from the charts.
  • Keep React out of the hot path. Store streaming data outside React state (a plain store or ref) and let the chart draw from it imperatively. React renders the chrome (filters, legends, layout), which changes rarely. Libraries like Zustand or a useSyncExternalStore subscription with throttling fit this well.

Part 4: Store it in a ring buffer

A dashboard that runs all day can't keep every point. Decide how much history the view needs (say the last 10 minutes at 2,000 points a second is 1.2 million points) and store exactly that, in fixed-size typed arrays that overwrite the oldest entries:

js
class RingBuffer {
  constructor(capacity) {
    this.t = new Float64Array(capacity)   // timestamps
    this.v = new Float32Array(capacity)   // values
    this.cap = capacity
    this.head = 0                         // next write position
    this.size = 0
  }
  push(t, v) {
    this.t[this.head] = t
    this.v[this.head] = v
    this.head = (this.head + 1) % this.cap
    if (this.size < this.cap) this.size++
  }
}

Why it matters:

  • Memory is constant. 1.2M points as Float64Array + Float32Array is about 14 MB, forever. The same points as JavaScript objects can easily take ten times that, and keep growing.
  • No garbage collection churn. Nothing is allocated per point, so there are no GC pauses causing dropped frames (FE-07).
  • Transferable. The arrays can move to and from a worker without copying.

Long-range history ("last 30 days") doesn't belong in the live buffer at all. Fetch it pre-aggregated from the server when the user zooms out.


Part 5: Downsample to the pixels you have

A 1,000-pixel chart with 1,200,000 points has 1,200 points per pixel column. Drawing all of them is pure waste: they land on the same pixels. Reduce them first. How you reduce them decides what users can see:

MethodKeepsUse for
Average per bucketThe trendSmooth metrics where spikes don't matter. Dangerous for monitoring: it hides exactly the outliers you're watching for.
Min/max per pixel columnEvery peak and troughMonitoring, latency, anything where a spike means an incident. Draw a vertical line from min to max in each column.
LTTB (Largest-Triangle-Three-Buckets)The visual shape, with a fixed number of pointsGeneral time-series charts. Described in Sveinn Steinarsson's 2013 thesis, and used by many chart libraries.

Rules of thumb:

  • Target about 1–2 points per pixel of chart width.
  • Re-downsample on zoom. Zooming in on 10 seconds should show the raw points again.
  • Downsample in the worker, next to the buffer, and send only the reduced arrays to the main thread.

Part 6: Rendering many tiles

A dashboard isn't one chart, it's dozens. Make the whole page cheap, not just one widget:

  • One animation loop for the page, not one timer per tile. A single requestAnimationFrame loop asks each visible, changed tile to draw. Forty setIntervals each forcing their own render is the most common cause of a busy dashboard.
  • Skip tiles that haven't changed (keep a dirty flag per tile) and tiles that are off-screen (an IntersectionObserver sets a visible flag).
  • Choose the renderer by point count. SVG is great for a few series of a few hundred points. Canvas handles tens of thousands. Beyond that, or with many charts animating at once, WebGL. Chapters 6 and 7 cover the trade-offs.
  • Avoid layout in the hot path. Updating a number's text can trigger layout. Give numeric tiles fixed sizes and font-variant-numeric: tabular-nums so changing digits don't shift anything.

Part 7: Hidden tabs, backpressure and reconnection

When the tab is hidden

Browsers stop rendering hidden tabs, and they throttle timers. Chrome, for example, can limit timers in a page that's been hidden for several minutes to waking once per minute. Messages keep arriving, though. If you keep queueing them, you get the story's freeze when the user comes back.

Never replay a backlog through the UI. On return, draw the current state once.

Backpressure

If the client can't keep up even when visible (a slow device, a burst), it should get less data, not more queueing:

  • Conflation on the server: for each key, send only the latest value per interval. A price that changed 50 times in 100 ms is sent once.
  • Subscribe to what's visible: tell the server which tiles and which zoom level are on screen, and only stream those.
  • Drop, don't queue, on the client for data that only matters as "latest value".

Reconnection

Connections drop. A robust client:

  • Reconnects with exponential backoff and jitter, so a thousand dashboards don't reconnect in the same second after a server deploy.
  • Uses sequence numbers to detect gaps, and re-syncs from a snapshot rather than trusting that nothing was missed.
  • Shows connection state and data age ("Live", "Reconnecting…", "Last updated 2 min ago"). A dashboard that silently shows stale data as if it were live is worse than one that says it's offline.

Numbers to remember

NumberMeaning
60–120 HzThe most often a screen can show a change
4–10 HzA sensible update rate for numbers people read
1–2 points per pixelTarget after downsampling
8 bytes / 4 bytesPer value in a Float64Array / Float32Array
~10×Typical memory overhead of storing points as JS objects vs typed arrays (varies by engine and shape)
50–100 msUseful server-side batching window

The DevTools workflow

  1. Network panel → the WebSocket connection → Messages: see the real message rate and size. Count messages per second before designing anything.
  2. Performance panel with the stream running for 10 seconds: look for many small tasks per frame (per-message renders) and GC blocks (allocation churn).
  3. Performance monitor: watch JS heap size and DOM nodes over several minutes. A heap that only goes up is an unbounded buffer or a leak.
  4. Memory panel → Allocation timeline: find what's being allocated per message.
  5. Hide the tab for a few minutes, then come back while recording. Any long task on return is backlog replay.
  6. Throttle the network and kill the connection (DevTools offline mode) to test reconnection and the stale-data UI.

Cheat sheet

ProblemFix
A render per messageBuffer messages, flush once per requestAnimationFrame
Numbers flickering unreadablyThrottle KPI tiles to 4–10 Hz
Memory grows all dayFixed-capacity ring buffer in typed arrays
Parsing thousands of messages/sServer-side batching, binary format, parse in a worker
More points than pixelsDownsample: min/max for monitoring, LTTB for trends
Spikes disappearStop averaging. Use min/max per column.
40 tiles, 40 timersOne rAF loop. Skip unchanged and off-screen tiles.
Freeze on returning to the tabDon't queue while hidden. Snapshot on return.
Client can't keep upServer conflation, subscribe only to what's visible, drop instead of queue
Connection dropsBackoff + jitter, sequence numbers, snapshot re-sync, visible "data age"

Say it in the interview

"The socket lives in a worker that batches and decodes messages into typed-array ring buffers sized for the visible time window, so memory is constant. The worker downsamples each series to about one min/max pair per pixel column, so spikes stay visible, and posts only that to the main thread. The main thread runs one requestAnimationFrame loop that redraws only visible, changed tiles on canvas, while KPI numbers update at 5 Hz. When the tab is hidden we stop rendering and keep only the latest state, and on return we draw from a fresh snapshot instead of replaying the backlog."


Go deeper