Skip to content

A live streaming chart ​

The whole thing assembled: history, a socket, a candle that forms in place, a volume pane, and a "go to now" button that appears only when the reader has scrolled away from the live edge.

js
const price = chart.addSeries(CandlestickSeries, {
    upColor: '#22ab94',
    downColor: '#f23645',
    borderUpColor: '#22ab94',
    borderDownColor: '#f23645',
    wickUpColor: '#22ab94',
    wickDownColor: '#f23645',
});

const volume = chart.addSeries(HistogramSeries, { priceScaleId: 'volume', base: 0 });

chart.priceScale('volume').applyOptions({ scaleMargins: { top: 0.78, bottom: 0 } });

const history = data.slice(0, 90);

price.setData(history);
volume.setData(history.map((bar, index) => ({
    time: bar.time,
    value: 400 + Math.abs(bar.close - bar.open) * 900 + (index % 7) * 60,
    color: bar.close >= bar.open ? 'rgba(34, 171, 148, 0.6)' : 'rgba(242, 54, 69, 0.6)',
})));

chart.timeScale().fitContent();

// A "go to now" button, shown only when the live edge is off screen.
const button = document.createElement('button');

button.textContent = 'Go to now ›';
button.style.cssText = 'position:absolute;right:70px;bottom:34px;z-index:3;display:none;'
    + 'padding:5px 10px;border:0;border-radius:8px;background:#0a0a0a;color:#fff;'
    + 'font:600 12px system-ui;cursor:pointer';
button.onclick = () => chart.timeScale().scrollToRealTime();
container.appendChild(button);

chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
    const live = price.data().length - 1;

    button.style.display = range && range.to < live ? 'block' : 'none';
});

// The feed. Eight ticks revise the forming bar, then a new period opens.
const period = 24 * 60 * 60;
let bar = { ...history[history.length - 1] };
let ticks = 0;

const feed = setInterval(() => {
    const last = bar.close + (Math.random() - 0.5) * 1.3;

    if (ticks >= 8) {
        bar = { time: bar.time + period, open: bar.close, high: last, low: last, close: last };
        ticks = 0;
    } else {
        bar = { ...bar, high: Math.max(bar.high, last), low: Math.min(bar.low, last), close: last };
        ticks++;
    }

    price.update(bar);
    volume.update({
        time: bar.time,
        value: 400 + Math.abs(bar.close - bar.open) * 900,
        color: bar.close >= bar.open ? 'rgba(34, 171, 148, 0.6)' : 'rgba(242, 54, 69, 0.6)',
    });
}, 450);

onCleanup(() => clearInterval(feed));

Scroll back a few bars — the button appears. Scroll to the right edge and it goes away.

The four decisions in that code ​

One setData, then only update. History arrives as an array and costs one pass. Every tick after it touches one reading. A hundred updates inside one frame still cost one redraw, because drawing is scheduled rather than immediate.

The whole bar, every tick. update replaces a reading; it does not patch one. Sending { time, close } to a candlestick series sets open, high and low to undefined and you get a hole where a candle was.

Never fitContent() on a tick. It refits the range on every reading, so the chart zooms out forever and the reader cannot hold a zoom level. Once, after setData, and then never again.

The chart does not follow on its own — and that is right. With the default shiftVisibleRangeOnNewBar: true the view moves only when the newest bar was already on screen. A reader who has scrolled back to March stays in March while ticks keep arriving. The "go to now" button is how they come back, deliberately.

The socket, for real ​

js
const socket = new WebSocket('wss://example.com/stream');

socket.addEventListener('message', (event) => {
    const message = JSON.parse(event.data);

    price.update({
        time: message.t / 1000,     // seconds, not milliseconds
        open: message.o,
        high: message.h,
        low: message.l,
        close: message.c,
    });
});

socket.addEventListener('close', () => {
    // Reconnect, then re-fetch history: what arrived while you were away is a
    // gap, and gaps are `setData`, not `update`.
    setTimeout(connect, 1000);
});

Reconnecting ​

The part people leave until it breaks in production. On reconnect you have a hole — whatever the feed sent while the socket was down. update cannot fill a hole, because it rejects anything earlier than the last reading it took.

So: fetch history again, setData, then resume update. It is one extra request on an event that should be rare, and it is the difference between a chart that recovers and one that quietly shows the wrong shape until someone reloads.

Throttling a fast feed ​

If the feed sends several hundred messages a second, keep the newest and apply it on a timer:

js
let pending = null;

socket.addEventListener('message', (event) => {
    pending = JSON.parse(event.data);
});

const timer = setInterval(() => {
    if (pending) {
        price.update(pending);
        pending = null;
    }
}, 100);

Ten frames a second reads as continuous, and the messages you skipped were each overwritten by the next one anyway.

Do this only if you have measured a problem. The chart coalesces redraws into one per frame on its own, so the usual reason to throttle is the work around the update — your own indicator maths, or a React render per tick.

Cleaning up ​

js
socket.close();
clearInterval(timer);
chart.remove();

All three. chart.remove() drops the canvases, listeners and observers, and it knows nothing about your socket.

What next ​