How to Build a Real-Time Dashboard with ApexCharts and WebSockets
The way to make an ApexCharts line scroll in real time is to give it a datetime x-axis with a fixed xaxis.range, then append each new reading with appendData. Appending keeps every existing point where it is, so as the newest point extends the axis, the range window slides across the fixed points and the line scrolls left. A WebSocket is just the source: its message handler calls that same appendData.
const chart = new ApexCharts(el, {
chart: { type: 'line', animations: { dynamicAnimation: { speed: 1000 } } },
series: [{ name: 'Throughput', data: [] }],
xaxis: { type: 'datetime', range: 30_000 }, // visible window: 30s
})
await chart.render()
socket.addEventListener('message', (event) => {
const { value } = JSON.parse(event.data)
chart.appendData([{ data: [{ x: Date.now(), y: value }] }])
})
That is the core of it. The rest of this tutorial turns those few lines into the dashboard below: a scrolling chart, a live gauge, and KPI tiles, all driven by one stream, plus the two details that decide whether it scrolls cleanly or looks broken (keeping memory bounded, and avoiding the in-place "warp"). The reference for every option is the Real-Time Charts doc, and you can see the full real-time dashboard demo in the gallery.
Here is what we are building. It runs live in your browser (a simulated feed stands in for the socket, but the append, trim, and teardown code is exactly what a real socket feed drives):
Key takeaways
- Append points with
appendData. Keeping existing points fixed is what letsxaxis.rangeslide across them and scroll the line. xaxis.rangesets the visible window, not the data. It never trims the series array, soappendDatagrows it over time.- Bound memory with an occasional trim, not a per-tick shift. Dropping one point every tick makes the line warp in place instead of scrolling.
- Keep the cadence even and match
dynamicAnimation.speedto it. Uneven gaps or a mismatched speed make the scroll jitter or lag. - A dashboard is one stream, many views. Derive KPI tiles and a gauge from the same tick rather than opening a socket per widget.
- Always tear down. Close the socket, clear intervals, and call
chart.destroy()on unmount, or you leak timers and update a dead chart.
Step 1: A chart that scrolls
A real-time line chart is an ordinary line chart with three settings that make it behave like a live feed. Use a datetime x-axis so points are placed by timestamp, set range to the window you want visible, and turn zoom off so an accidental drag does not fight the incoming data.
const options = {
chart: {
type: 'line',
height: 260,
toolbar: { show: false },
zoom: { enabled: false }, // a zoom state gets reset by the next update
animations: {
enabled: true,
dynamicAnimation: { speed: 1000 }, // match the update interval below
},
},
series: [{ name: 'Throughput', data: [] }],
xaxis: {
type: 'datetime',
range: 30_000, // keep the last 30 seconds on screen
},
yaxis: { min: 0 },
}
const chart = new ApexCharts(document.querySelector('#chart'), options)
await chart.render()
range fixes the visible span at 30 seconds by computing the axis max from your latest point and min as max - range. One thing it does not do is trim your data: range is a view setting, so points scrolled off the left edge are still in the array. Bounding memory is your job, which Step 2 handles.
Step 2: Feed it with appendData
Add each point with appendData. Because it appends without moving the existing points, each new point extends the axis and the whole line translates left by one step. That is the scroll.
const intervalId = setInterval(() => {
chart.appendData([{ data: [{ x: Date.now(), y: readCurrentValue() }] }])
}, 1000)
Two details keep it clean. First, keep the cadence even. A steady 1 Hz feed gives evenly spaced points, so each scroll step is the same width; irregular arrivals make the speed visibly jitter. If you control the source, stamp x on the server at a fixed interval. Second, remember that appendData only pushes and never removes, so the array grows for as long as the stream runs. Trim it occasionally, when the oldest points are safely off-screen:
// Every so often (not every tick), drop the old off-screen points.
if (series.length > 3 * VISIBLE) {
const trimmed = series.slice(-2 * VISIBLE)
chart.updateSeries([{ data: trimmed }], false) // `false` = no animation
}
The false second argument matters: it replaces the series without an animation. Because the points you removed were off-screen, the visible line does not change, so the trim is invisible. This is the one safe way to shrink the array. Do not trim by dropping the oldest point on every tick and calling updateSeries each time. Replacing a shifting window each tick makes the animation a shape morph rather than a translate: under the rescaled axis every point lands at the same pixel, so the line appears to warp in place and "crawl" instead of scrolling.
Step 3: Swap polling for a WebSocket
A WebSocket changes only where the value comes from. The interval callback becomes a message handler, and everything downstream is identical.
const socket = new WebSocket('wss://your-api.example.com/stream')
socket.addEventListener('message', (event) => {
const { value } = JSON.parse(event.data)
// Fire-and-forget: do NOT await inside the handler.
chart.appendData([{ data: [{ x: Date.now(), y: value }] }])
})
socket.addEventListener('close', () => {
// See "How do I handle reconnects" below.
})
The one rule that matters here: do not await appendData inside the handler. Messages can arrive faster than the browser paints frames. If you await, each message waits for the previous render to finish before it is processed, and the handler queues up behind a backlog it can never clear. For a very high-rate feed, do not touch the chart on every message at all: buffer the incoming values and append the newest batch on a timer or requestAnimationFrame, so your render rate is decoupled from your message rate. If your server emits at a fixed cadence, use the timestamp in the payload as x so the scroll steps stay even.
Step 4: Turn one stream into a dashboard
A dashboard is not five sockets and five charts. It is one stream, read many ways. In the demo above, a single tick appends to the scrolling chart, recomputes the KPI tiles (current, session peak, session average), and drives the gauge. Fan out in your handler:
socket.addEventListener('message', (event) => {
const { value } = JSON.parse(event.data)
// 1. The scrolling time series
areaChart.appendData([{ data: [{ x: Date.now(), y: value }] }])
// 2. A gauge: updateSeries with a single current value
gaugeChart.updateSeries([Math.round((value / CAPACITY) * 100)])
// 3. Plain DOM for the number tiles: no chart needed
peak = Math.max(peak, value)
renderKpis({ current: value, peak })
})
Two things worth copying from that snippet. First, the gauge takes a single value, not a history, so it is a one-element updateSeries rather than an append. Second, not every tile needs to be a chart: the KPI numbers are just text nodes updated from the same value, which is far cheaper than three more chart instances. Reach for a chart only where you are showing a shape over time.
For more on appendData, updateSeries, and the other data methods, see Update Chart Data Dynamically.
Why does my chart stutter, warp, or lag?
Three causes account for almost all of it.
Uneven point spacing. If the gap between points varies (for example, stamping x with Date.now() under an imprecise timer or a throttled background tab), each scroll step is a different width and the motion jitters. Use a steady cadence, or server timestamps emitted at a fixed interval.
A speed and interval mismatch. ApexCharts animates from the old frame to the new one over dynamicAnimation.speed milliseconds. If that is shorter than your interval, the animation finishes early and the chart sits still until the next point (a stutter); if it is longer, animations pile up and the chart drifts behind. Set them equal.
Bounding memory the wrong way. Dropping the oldest point and replacing the whole series every tick does not scroll: under the rescaled axis, each point keeps its pixel position and only its value changes, so the line warps in place. Append instead, and trim only occasionally (Step 2). If you are pushing thousands of updates a minute, rendering cost becomes the ceiling, which is why the SVG vs Canvas trade-off is worth understanding before you get there.
How do I handle reconnects and gaps?
A production stream will drop. Sockets close, laptops sleep, proxies time out. Two habits keep the chart honest through it:
- Reconnect with backoff. On
close, wait and reopen, increasing the delay each failed attempt so you do not hammer a downed server. When the socket reopens, resume appending; the fixedrangemeans the gap simply scrolls away within one window. - Do not fabricate points across a gap. If you were holding the last value and re-emitting it on a timer, a dropout would draw a flat line that looks like real data. Prefer a genuine gap: stop appending while disconnected, and the missing seconds show as empty space, which is the truthful picture.
let delay = 1000
function connect() {
const socket = new WebSocket(URL)
socket.addEventListener('open', () => { delay = 1000 })
socket.addEventListener('message', onMessage)
socket.addEventListener('close', () => {
setTimeout(connect, delay)
delay = Math.min(delay * 2, 30_000) // cap the backoff at 30s
})
}
connect()
Cleaning up
The most common real-time bug is not visual, it is a leak. If the interval or socket keeps running after the chart is gone, every tick calls appendData on a destroyed instance, which throws and holds references alive. Pair every source with its teardown.
In React, hold the instance in a ref so the handler always sees the current chart without re-rendering, and return the cleanup from useEffect:
import { useEffect, useRef } from 'react'
import ApexCharts from 'apexcharts'
function LiveChart() {
const elRef = useRef(null)
const chartRef = useRef(null)
useEffect(() => {
const chart = new ApexCharts(elRef.current, options)
chartRef.current = chart
chart.render()
const socket = new WebSocket(URL)
socket.addEventListener('message', (event) => {
const { value } = JSON.parse(event.data)
chartRef.current?.appendData([{ data: [{ x: Date.now(), y: value }] }])
})
return () => {
socket.close()
chart.destroy()
}
}, [])
return <div ref={elRef} />
}
The empty dependency array matters: the effect runs once on mount and its cleanup runs once on unmount, so you get exactly one chart and one socket for the component's life. The same shape applies elsewhere. In Vue, create in onMounted and tear down in onUnmounted; in Angular, use ngOnInit and ngOnDestroy. For the framework specifics, see the React, Vue, and Angular integration guides.
Summary
A real-time dashboard in ApexCharts comes down to a handful of decisions. Use a datetime axis with a fixed xaxis.range for the visible window, but remember it does not trim your data. Add points with appendData so existing points stay put and the line scrolls, keep the cadence even, and match dynamicAnimation.speed to it. Bound memory with an occasional non-animated updateSeries trim, never a per-tick shift. Treat the WebSocket as a source that appends, and never await inside the handler. Build the dashboard from one stream read many ways rather than a socket per widget. And clean up the socket and the chart on unmount so a leak does not undo all of it.
Start from the Real-Time Charts documentation for the full option reference, try the live realtime dashboard demo and the simpler realtime line demo, and if you are about to push a chart to very high update rates, read SVG vs Canvas for charts first to understand where the rendering ceiling is.
Frequently asked questions
How do I make an ApexCharts chart update in real time?
Give the chart a datetime x-axis with a fixed xaxis.range (the visible window in milliseconds). Then, on each new reading, add the point with chart.appendData([{ data: [{ x, y }] }]). Because existing points keep their position, the range window slides across them and the line scrolls left. Set chart.animations.dynamicAnimation.speed to match how often points arrive, and keep the gap between points even so the scroll speed stays steady.
Should I use appendData or updateSeries for streaming?
Use appendData to add points: it keeps existing points fixed, which is what makes the line scroll smoothly. Its one drawback is that it only pushes and never removes, so the array grows over time. Pair it with an occasional non-animated updateSeries that trims old off-screen points. Do not try to bound memory by dropping one point every tick: replacing a shifting window each tick makes the line warp in place instead of scrolling, because every point's index shifts under the rescaled axis.
How do I connect ApexCharts to a WebSocket?
Open the socket, and in its message handler parse the payload and call chart.appendData with the new point. Do not await the call inside the handler: messages can arrive faster than render frames, and awaiting would queue them up. For very high-rate feeds, buffer incoming messages and flush to the chart on an interval or animation frame. Close the socket and call chart.destroy() when the view unmounts.
Does xaxis.range keep the data array from growing forever?
No. xaxis.range only sets the visible window (it computes the axis min and max); it does not remove points from the underlying series. appendData only pushes, so the array grows without bound and memory climbs over time. To keep memory flat, periodically trim the old off-screen points yourself with a non-animated updateSeries (or slice the array), rather than on every tick.
Why does my streaming chart stutter, warp, or fall behind?
Three usual causes. Uneven gaps between points (for example stamping x with Date.now() under an imprecise timer) make the scroll speed jitter; use a steady cadence or server timestamps. A mismatch between dynamicAnimation.speed and your update interval makes it idle or lag; set them equal. And replacing a drop-oldest window every tick makes the line warp in place instead of scrolling; append points and trim only occasionally instead.
Does real-time streaming work in React, Vue, and Angular?
Yes. The chart API is identical; only the lifecycle wiring differs. In React, hold the instance in a useRef and start the socket in useEffect, returning the cleanup. In Vue, set up in onMounted and tear down in onUnmounted. In Angular, use ngOnInit and ngOnDestroy. In every case the rule is the same: keep one chart instance, and clean up the feed when the component goes away.