Built with ApexStock, ApexCharts.js
A live price chart has one decision at its centre, and it is not which WebSocket library to use. It is what you do when a tick arrives: replace the series, or append to it. Replacing is one line and it is wrong at tick rate. Appending keeps the chart usable while it updates.
Should I replace the series or append to it?
Replacing the series rebuilds the chart. Every indicator recomputes over the full history, panes are destroyed and recreated, and the view resets. At one tick a second, the reader is looking at a chart that rebuilds itself under their cursor.
appendData exists for the other path:
update({ series }) | appendData(bar) | |
|---|---|---|
| Cost per tick | O(full history) per indicator | O(active indicators x small tail) |
| Zoom window | Reset | Kept (view: 'preserve') or follows the edge |
| Drawings and price lines | Survive | Survive |
| Indicator panes | Destroyed and recreated | Updated in place |
| Right for | A symbol change, a timeframe change | Every tick |
The rule is simple. update when the reader asked for different data.
appendData when the same instrument produced another bar.
What does a tick actually do to the chart?
This is the part that is usually got wrong, because "a tick arrived" and "a bar closed" are different events and only one of them adds a candle.
A one-minute candle receives dozens of ticks. For all but the last, the bar is forming: its close moves, its high and low may extend, and the bar count does not change. When the minute rolls over, the next tick starts a new bar.
appendData handles both through one flag:
// A tick for the bar currently forming: replace it, do not append.
chart.appendData(
{ x: barOpenTime, y: [open, high, low, price], v: volume },
{ updateLast: true },
)
// The minute rolled over: this is a new bar.
chart.appendData({ x: nextBarOpenTime, y: [p, p, p, p], v: 0 })
With updateLast: true, a point whose x equals the last bar's x replaces
it. Without the flag, the same call appends, and you get a chart with one candle
per tick: hundreds of one-second candles inside what should be a single minute.
Nothing errors. The chart just quietly stops meaning what it says.
Deriving the bar's open time from the tick timestamp is your job, not the library's:
const BAR_MS = 60_000
const barOpen = (t) => Math.floor(t / BAR_MS) * BAR_MS
let bar = null
function onTick({ time, price, size }) {
const open = barOpen(time)
if (!bar || open > bar.x) {
// A new bar. Commit the previous one by simply appending this.
bar = { x: open, y: [price, price, price, price], v: size }
chart.appendData(bar, { maxPoints: 1500 })
return
}
// Same bar, still forming: extend high/low, move the close.
bar.y[1] = Math.max(bar.y[1], price)
bar.y[2] = Math.min(bar.y[2], price)
bar.y[3] = price
bar.v += size
chart.appendData(bar, { updateLast: true, view: 'preserve' })
}
Should the chart follow the right edge?
view decides, and the honest default depends on what the reader is doing.
view: 'follow'(the default) shifts a zoomed window to include the new bar.view: 'preserve'leaves the window exactly where it is.
Following the edge is right until the reader zooms in to inspect something. Then it drags the chart out from under them once a second. A live chart that respects the reader tracks whether they have panned away from the edge and switches:
const atRightEdge = () => {
const { max } = chart.getVisibleRange()
return max >= lastBarTime - BAR_MS // within one bar of the edge
}
chart.appendData(bar, { view: atRightEdge() ? 'follow' : 'preserve' })
How do I stop the buffer growing forever?
maxPoints trims the oldest bars so the buffer stays fixed width:
chart.appendData(bar, { maxPoints: 1500 })
Verified against apexstock 0.5.0: seeding 200 bars and streaming 60 with
maxPoints: 25 leaves the price series at 25 points, and the indicator pane
trims with it. The same run without maxPoints ends at 260.
One thing worth checking rather than assuming. The type definitions warn that a trimmed, streamed chart shows different indicator values from a cold reload of the same window, because running indicators carry state across the trim. Testing it on 0.5.0 with RSI(14), seeding 200 bars, streaming 60 and capping at 25, the streamed values came out identical to a fresh chart built from those same 25 bars, to the last decimal the library reports. So for RSI the warning did not reproduce. Treat the docs as the cautious position and measure your own indicator if the distinction matters to you, because a value that depends on how the reader arrived at the window is a hard bug to see.
Which indicators can actually stream?
Not all of them. listIndicators() reports a streamable flag per entry, and
reading it off apexstock 0.5.0 at runtime: 21 of the 24 registry entries are
streamable. Three are not:
| Not streamable | Why it matters |
|---|---|
| Ichimoku Cloud | Forward-shifted spans; a tail update cannot produce them |
| Fibonacci Retracements | Anchored to a chosen range, not to the last bar |
| Volumes | Rendered as its own pane rather than a streaming twin |
An indicator without a streaming twin still works. It just cannot be updated incrementally, so it is recomputed rather than extended. If you are running a fast tick loop, know which of your active indicators fall in this group before you blame the transport.
// Ask the library rather than hard-coding a list; the registry is the truth.
const notStreamable = chart
.listIndicators()
.filter((i) => !i.streamable)
.map((i) => i.key)
What breaks first: the teardown
This is the failure that costs you an afternoon, and it is specific to single-page apps, which is to say most of the places a live chart lives.
destroy() in apexstock 0.5.0 cleans up nearly everything. Mounting a chart,
destroying it, and repeating, measured in a real browser:
| After 1 cycle | After 2 | After 3 | |
|---|---|---|---|
| Chart DOM inside the host | clean | clean | clean |
| Leaked intervals | 0 | 0 | 0 |
| Orphaned indicator toolbars | 1 | 2 | 3 |
The chart DOM and the timers are genuinely released. But one indicator
toolbar is left behind per mount, attached to document.body rather than to
your host element, carrying its dropdown and export button with it. It
accumulates exactly one per cycle.
The type definitions state that destroy() "guarantees no listener/DOM leak on
SPA unmount". For the toolbar, on 0.5.0, that is not the case.
You notice it in React, because Strict Mode mounts, unmounts and remounts every component in development. One chart, two toolbars, stacked over each other, and nothing in the console.
The fix is to own the teardown rather than trust it. Give the chart a wrapper you create and then remove outright, so anything the library parented elsewhere goes with it:
function mountStockChart(host, options) {
const shell = document.createElement('div')
host.appendChild(shell)
let instance = new ApexStock(shell, options)
instance.render()
return {
instance,
destroy() {
try {
instance?.destroy()
// Also tear down the underlying ApexCharts, which destroy() may leave.
instance?.chart?.destroy?.()
} finally {
instance = null
// The reset that actually matters: removes the orphaned toolbar too.
shell.remove()
host.replaceChildren()
}
},
}
}
In React, that becomes one effect with a real cleanup:
useEffect(() => {
const handle = mountStockChart(hostRef.current, options)
return () => handle.destroy()
}, [])
host.replaceChildren() is what makes this robust. It does not matter which
elements the library parented where, because the host ends up empty either way.
When to use something else
| Situation | Reach for |
|---|---|
| One line updating every few seconds, no indicators or drawings | ApexCharts.js on its own. A stock chart is a lot of machinery for a sparkline. |
| Thousands of points arriving faster than a frame | Batch. appendData accepts an array, so buffer ticks and flush once per animation frame instead of once per message. |
| Analytics on the stream, not a picture of it | ApexStock.stats. It computes returns, volatility and drawdown with no chart and no DOM, so it runs in a worker or on a server. |
| Replaying history fast | Seed with the full array and render once. Streaming a backfill bar by bar is slower than one render. |
| The reader changed symbol or timeframe | update(). That is the case it is for, and appending would splice two instruments into one series. |
Which plans include ApexStock?
Premium and OEM. ApexStock is the most narrowly licensed product in the family: neither the Community tier nor Pro includes it, so the under-$2M revenue waiver that covers ApexCharts.js does not extend to it. Nothing in the family is open source, and source published on GitHub is not an open licence.
Every feature renders in full without a licence key, watermarked, so you can build the whole streaming path against your own feed before deciding. The pricing page has the matrix.
A live candlestick chart updating from a simulated feedSee the pieces running
Reference documentation
Frequently Asked Questions
How do I update a candlestick chart in real time without it flickering?
Use appendData rather than replacing the series. Replacing rebuilds the chart: every indicator recomputes over the full history, the indicator panes are destroyed and recreated, and the zoom resets. appendData updates the price candles, every streamable indicator, the volume pane and the axis in place, at a cost proportional to the active indicators and a small tail rather than to the whole history. Reserve update() for when the reader changes symbol or timeframe.
How do I update the currently forming candle instead of adding a new one?
Pass { updateLast: true } and give the point the same x as the bar being formed. appendData then replaces that bar instead of appending. Without the flag the same call appends, so a one-minute candle receiving one tick a second becomes sixty separate candles. Nothing errors, which is what makes it easy to miss. Derive the bar's open time yourself, for example Math.floor(tickTime / 60000) * 60000, and start a new bar only when that value changes.
How do I stop a streaming chart growing without limit?
Pass maxPoints to appendData and it trims the oldest bars so the buffer stays fixed width. Verified on apexstock 0.5.0: seeding 200 bars then streaming 60 with maxPoints 25 leaves the price series at 25 points and trims the indicator pane with it, where the same run without maxPoints ends at 260.
Do all technical indicators support streaming updates?
Almost all. Calling listIndicators() on apexstock 0.5.0 reports a streamable flag per entry: 21 of the 24 registry entries are streamable, and three are not, namely the Ichimoku Cloud, Fibonacci Retracements and Volumes. Those still work, but they are recomputed rather than extended incrementally, so read the flag from the registry instead of hard-coding a list.
Does destroy() fully clean up a stock chart?
Not quite, on 0.5.0. The chart DOM inside your host element is removed and no intervals leak, both of which were problems in earlier versions. But one indicator toolbar is left attached to document.body per mount-and-destroy cycle, accumulating one per cycle, which contradicts the type definitions. You see it in React, where Strict Mode remounts every component in development and you end up with two toolbars over one chart. Mount into a wrapper element you create and remove that wrapper on teardown, then call host.replaceChildren(), so anything parented elsewhere goes with it.
Related
Watch it update live
The streaming demo appends bars from a simulated feed with indicators active, which is the shape this recipe describes.