Real-Time Charts
ApexCharts supports real-time streaming charts through two mechanisms: appendData, which pushes new points onto an existing series, and updateSeries, which replaces the entire data window. Both become smooth scrolling animations when combined with dynamicAnimation. The result is a chart whose line scrolls left continuously as new data arrives, rather than redrawing from scratch each tick.
Polling with setInterval
The simplest real-time pattern is a fixed setInterval that appends one point per tick. The xaxis.range option keeps the visible window fixed at a set number of milliseconds. ApexCharts discards points older than maxX - range automatically, so the data array never grows unbounded.
import ApexCharts from 'apexcharts'
const options = {
chart: {
type: 'line',
animations: {
enabled: true,
easing: 'linear', // linear keeps the scroll feel steady
dynamicAnimation: {
speed: 1000 // must match the update interval in ms
}
},
toolbar: { show: false },
zoom: { enabled: false } // prevent accidental zoom while watching live data
},
series: [{ name: 'Sensor', data: [] }],
xaxis: {
type: 'datetime',
range: 30_000 // show a 30-second rolling window
},
yaxis: {
min: 0,
max: 100
}
}
const chart = new ApexCharts(document.querySelector('#chart'), options)
await chart.render()
const intervalId = setInterval(() => {
chart.appendData([{
data: [{ x: Date.now(), y: Math.random() * 100 }]
}])
}, 1000)
// On page unload or component unmount:
// clearInterval(intervalId)
// chart.destroy()
Why dynamicAnimation.speed must match the interval: ApexCharts animates from the previous position to the new one over speed milliseconds. If speed is shorter than the interval, the animation finishes early and the chart sits still until the next tick, creating a stutter. If speed is longer than the interval, animations pile up and the chart lags further and further behind. Setting them equal means each animation completes exactly as the next point arrives, producing a continuous scroll.
Why easing: 'linear': Non-linear easings like easein or easeout accelerate or decelerate within each interval. For streaming data, that creates a pulsing motion that looks unnatural. Linear easing maintains a constant scroll speed that matches the human expectation of a real-time feed.
Why zoom: { enabled: false }: When a user drags to zoom on a live chart, the next appendData call resets the zoom range. Disabling zoom prevents that jarring experience.
Sliding window with updateSeries
When you are not using xaxis.range (for example, with category axes or when you want precise control over the buffer size), manage the window yourself and call updateSeries once the array is full.
async function push(chart, value) {
const data = chart.w.config.series[0].data
const maxPoints = 50
if (data.length >= maxPoints) {
// Shift the oldest point out, append the new one, replace the whole series
await chart.updateSeries([{
data: [...data.slice(1), { x: Date.now(), y: value }]
}])
} else {
// Buffer is not yet full; keep appending
await chart.appendData([{ data: [{ x: Date.now(), y: value }] }])
}
}
Why updateSeries is faster at high frequency: When the series count stays the same and no zoom or hidden-series state is active, ApexCharts takes an internal fast path: it recomputes scale and redraws only the series paths, leaving the grid, axes, legend, and tooltip DOM untouched. appendData never takes this path. For updates arriving faster than once per second, the updateSeries sliding-window approach produces less work per frame.
Accessing the current data: chart.w.config.series[0].data reads the live internal data array. This is the stable way to get the current data for slicing.
WebSocket streaming
Wire a WebSocket directly to appendData. The pattern is the same as polling, but the event listener replaces the interval.
import ApexCharts from 'apexcharts'
const chart = new ApexCharts(document.querySelector('#chart'), {
chart: {
type: 'line',
animations: {
enabled: true,
easing: 'linear',
dynamicAnimation: { speed: 500 }
},
toolbar: { show: false },
zoom: { enabled: false }
},
series: [{ name: 'Price', data: [] }],
xaxis: { type: 'datetime', range: 60_000 },
yaxis: { min: 0 }
})
await chart.render()
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 here
chart.appendData([{ data: [{ x: Date.now(), y: value }] }])
})
// On page unload or component unmount:
// socket.close()
// chart.destroy()
Do not await appendData inside a WebSocket handler. Messages can arrive faster than render cycles. Awaiting would cause the handler to queue up unprocessed messages, and each subsequent message would wait for the previous render to complete before being processed. Fire-and-forget lets ApexCharts batch or skip frames as needed to stay current.
Multi-series real-time
Pass one entry per series in the appendData array. The entries are matched positionally to the existing series.
chart.appendData([
{ data: [{ x: Date.now(), y: sensor1.read() }] }, // series[0]
{ data: [{ x: Date.now(), y: sensor2.read() }] } // series[1]
])
All series share the same xaxis.range window. If you need different Y scales, configure multiple Y axes with the yaxis array and assign each series to an axis by index.
Common mistakes
Not matching dynamicAnimation.speed to the update interval
If speed does not match the interval, the chart either idles between ticks (speed too low) or falls behind accumulating animations (speed too high). Always set them to the same value.
Leaving zoom enabled on a live chart
zoom: { enabled: false } is not just UX polish. A zoom state changes the internal axis range, and the next appendData call resets it, which causes a visible snap. Disable zoom on any chart that is receiving continuous updates.
Not cleaning up on unmount
If the interval or socket continues running after the chart is destroyed, each subsequent appendData call targets a destroyed chart instance. This leaks memory and may throw runtime errors. Always pair your setup with cleanup:
// Good pattern: capture both handles, clean up both
const intervalId = setInterval(tick, 1000)
window.addEventListener('beforeunload', () => {
clearInterval(intervalId)
chart.destroy()
})
In React, return the cleanup from useEffect. In Vue, use onUnmounted. In Angular, use ngOnDestroy.
React example
Use useRef to hold the chart instance so the interval callback always has access to the current chart without triggering re-renders. Set up the interval in useEffect and return the cleanup function.
import { useRef, useEffect } from 'react'
import ReactApexChart from 'react-apexcharts'
function RealtimeChart() {
const chartRef = useRef(null)
const series = [{ name: 'Sensor', data: [] }]
const options = {
chart: {
type: 'line',
animations: {
enabled: true,
easing: 'linear',
dynamicAnimation: { speed: 1000 }
},
toolbar: { show: false },
zoom: { enabled: false }
},
xaxis: {
type: 'datetime',
range: 30_000
},
yaxis: {
min: 0,
max: 100
}
}
useEffect(() => {
const id = setInterval(() => {
chartRef.current?.appendData([{
data: [{ x: Date.now(), y: Math.random() * 100 }]
}])
}, 1000)
return () => clearInterval(id)
}, [])
return (
<ReactApexChart
ref={chartRef}
type="line"
series={series}
options={options}
height={350}
/>
)
}
Why useRef instead of state: Storing the chart instance in useState would trigger a re-render when it changes, which would recreate the ReactApexChart component and destroy the underlying instance. useRef holds a mutable reference that persists across renders without causing them.
Why the empty dependency array on useEffect: The interval should start once when the component mounts and stop when it unmounts. Listing dependencies would restart the interval when those values change, which is not the behavior you want for a continuous stream.
For the full appendData and updateSeries API reference, including how to skip a series or update multiple series at different rates, see Update Chart Data Dynamically.