Streaming Time Series into Charts Permalink to this section
Part of Live Dashboards & Metrics Feeds, under Real-Time Application Patterns.
A live chart is harder than a live number. A number only needs its latest value; a chart needs a continuous history, which means the stream must fill in what the client missed, the client must reject what it already has, and something must stop the history growing forever. This guide builds a chart feed that handles all three, with code that works for any charting library that accepts arrays of points.
Symptom & Developer Intent Permalink to this section
Typical failures of a naively streamed chart:
- A flat line or a straight diagonal segment appears across every period the connection was down.
- After a reconnect, the same points are plotted twice, producing a visible zig-zag or a doubled spike.
- Points arrive slightly out of order and the line doubles back on itself.
- The chart gets slower every hour it is open and eventually freezes the tab.
- A newly opened chart starts empty and fills in over the following minutes.
The intent is a chart that opens with the recent window already drawn, extends smoothly in real time, repairs gaps after reconnects, and uses constant memory.
Root Cause Analysis Permalink to this section
Time series have the append-only shape, not the current-state shape, so the snapshot-only reconnect used for gauges is not enough. A client reconnecting after twenty seconds needs the twenty seconds of points it missed, or the chart shows a gap. Unlike a notification feed, though, it does not need them all forever — only the ones inside the visible window.
Three mechanics cause the other symptoms. Duplicates come from overlap: the server’s backfill starts from the client’s cursor, but the client may have already received a point with a timestamp equal to the cursor. Out-of-order points come from multiple producers or brokers that do not preserve order. Unbounded growth comes from pushing every point into the chart’s data array, which charting libraries then re-process in full on every update.
Step-by-Step Resolution Permalink to this section
Step 1 — Use the point timestamp as the event id Permalink to this section
A time series already has a natural, seekable cursor: the timestamp of the last point. Using it as the SSE id means the browser returns it as Last-Event-ID on reconnect with no extra code.
event: points
id: 1726650042000
data: {"series":"rps","points":[[1726650041000,1290],[1726650042000,1302]]}
Batching several points into one event, keyed by the newest timestamp, keeps frame overhead low at high sample rates.
Step 2 — Backfill from the cursor on every connection Permalink to this section
// Server: on connect, send the visible window (new client) or the gap (reconnect).
const WINDOW_MS = 15 * 60 * 1000;
app.get('/charts/:series/stream', async (req, res) => {
openStream(res);
const now = Date.now();
const cursor = Number(req.get('Last-Event-ID') ?? 0);
// Never backfill further than the visible window, however old the cursor.
const from = Math.max(cursor + 1, now - WINDOW_MS);
const history = await tsdb.range(req.params.series, from, now); // [[t, v], ...]
for (const chunk of chunks(history, 500)) {
const last = chunk[chunk.length - 1][0];
res.write(`event: points\nid: ${last}\ndata: ${JSON.stringify({ series: req.params.series, points: chunk })}\n\n`);
}
const off = live.subscribe(req.params.series, (pts) => {
const fresh = pts.filter(([t]) => t > cursorOf(res)); // skip anything already backfilled
if (!fresh.length) return;
setCursor(res, fresh[fresh.length - 1][0]);
res.write(`event: points\nid: ${cursorOf(res)}\ndata: ${JSON.stringify({ series: req.params.series, points: fresh })}\n\n`);
});
req.on('close', off);
});
Subscribe to the live feed before reading history, and filter the live points by the per-connection cursor. That order guarantees no point published during the history query is lost, and the cursor filter guarantees none is sent twice.
Step 3 — Merge points into a bounded, sorted buffer on the client Permalink to this section
// ring-series.js — fixed-capacity, sorted, de-duplicated by timestamp.
export class RingSeries {
constructor(windowMs) { this.windowMs = windowMs; this.t = []; this.v = []; }
add(points) {
for (const [t, v] of points) {
const last = this.t.length ? this.t[this.t.length - 1] : -Infinity;
if (t > last) { this.t.push(t); this.v.push(v); continue; } // common fast path
const i = lowerBound(this.t, t);
if (this.t[i] === t) { this.v[i] = v; continue; } // duplicate: overwrite
this.t.splice(i, 0, t); this.v.splice(i, 0, v); // late point: insert
}
const cutoff = this.t[this.t.length - 1] - this.windowMs;
const drop = lowerBound(this.t, cutoff);
if (drop > 0) { this.t.splice(0, drop); this.v.splice(0, drop); } // trim to the window
}
}
function lowerBound(a, x) {
let lo = 0, hi = a.length;
while (lo < hi) { const m = (lo + hi) >> 1; if (a[m] < x) lo = m + 1; else hi = m; }
return lo;
}
The fast path handles the overwhelmingly common case — a point newer than everything held — in constant time. Duplicates overwrite, late points are inserted in order, and the window trim keeps memory flat.
Step 4 — Update the chart once per frame, with the library’s cheapest API Permalink to this section
const series = new RingSeries(15 * 60 * 1000);
let dirty = false;
es.addEventListener('points', (e) => {
series.add(JSON.parse(e.data).points);
dirty = true;
});
(function frame() {
if (dirty) {
dirty = false;
// Most libraries accept parallel arrays or a typed array; avoid rebuilding objects per point.
chart.setData([series.t, series.v]);
}
requestAnimationFrame(frame);
})();
Libraries differ in what an update costs. Canvas-based libraries that accept columnar arrays redraw tens of thousands of points in a few milliseconds; SVG-based libraries that create one DOM node per point become slow in the low thousands. For streaming use, pick a canvas renderer or downsample before rendering, as the frame-rate throttling guide explains for the general case.
Step 5 — Downsample on the server for long windows Permalink to this section
A 24-hour window at one point per second is 86,400 points — more than there are horizontal pixels on any screen. Let the client state its pixel width and have the server downsample the backfill (min/max per bucket preserves spikes better than averages), while streaming live points at full resolution for the most recent minutes.
// ?width=1200 → at most 1,200 buckets, each carrying the min and max of its range.
const buckets = Math.min(Number(req.query.width) || 1000, 4000);
const history = await tsdb.rangeMinMax(series, from, now, buckets);
Validation & Monitoring Permalink to this section
Prove the three properties — no gaps, no duplicates, bounded memory — separately.
# Gap repair: reconnect with a cursor 30 s old and count the backfilled points.
ts=$(( ($(date +%s) - 30) * 1000 ))
curl -sN -H "Last-Event-ID: $ts" https://app.example.com/charts/rps/stream \
| grep -m1 '^data:' | jq '.points | length'
# Expect ~30 at 1 point/s.
# Duplicate check: every id must be strictly greater than the previous one.
curl -sN https://app.example.com/charts/rps/stream | grep --line-buffered '^id:' \
| awk '{ if ($2 <= prev) print "NON-MONOTONIC", prev, $2; prev = $2 }'
In the browser, toggle DevTools’ network throttling to Offline for twenty seconds and back: the chart should close the gap within one frame of the reconnect. For memory, take heap snapshots an hour apart with the chart open — retained size should be flat.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Should each data point be its own SSE event?
At one point per second, it does not matter. Above a few points per second per series, batch them: one event carrying an array of points, with the newest timestamp as the id, cuts frame overhead and client dispatch work proportionally.
What if two points share the same timestamp?
Treat the timestamp as the key and let the later arrival overwrite. If the source can legitimately produce distinct points at the same millisecond, use a sequence number as the id and cursor instead of the timestamp.
How far back should the server backfill after a long disconnection?
Only as far as the chart displays. A client that was away for a day needs the current fifteen-minute window, not a day of points it will immediately trim.
Can the server send pre-aggregated points for live data too?
Yes, and at high source rates it should. Aggregate to the resolution the chart can show — for example one point per second — on the server, and stream the aggregates. Clients should never receive more resolution than they can plot.