A candlestick chart looks simple. Each candle has an open, high, low, and close. Display them in a row. But behind that simple visualisation is a pipeline that takes raw trade data, aggregates it into time buckets, merges it with historical data, and updates the display without jank or data loss.
Getting this pipeline right is the difference between a chart that traders trust and one they don't.
The data pipeline
The flow looks like this:
- Historical candles arrive via REST API on initial load
- Live trades stream in via WebSocket
- Trades are aggregated into the current (partial) candle
- The partial candle is merged with the historical series
- When a time boundary crosses, the partial candle is finalised and a new one begins
- The chart library renders the updated series
Each step has subtleties that trip up developers who have not built this before.
Loading historical data
Start by fetching historical OHLCV data for the selected timeframe and instrument:
interface Candle { time: number; // Unix timestamp in seconds open: number; high: number; low: number; close: number; volume: number; } async function fetchCandles( symbol: string, interval: string, limit: number ): Promise<Candle[]> { const response = await fetch( `/api/candles?symbol=${symbol}&interval=${interval}&limit=${limit}` ); return response.json(); }
Most exchanges return candles with the timestamp representing the start of the period. Make sure your chart library expects the same convention. A mismatch here shifts every candle by one period, which is a subtle but serious bug.
Aggregating trades into candles
Live trades need to be folded into the current candle. This means tracking the candle's time bucket and updating OHLCV values as each trade arrives:
function aggregateTrade(candle: Candle | null, trade: Trade, intervalMs: number): Candle { const bucketTime = Math.floor(trade.timestamp / intervalMs) * intervalMs; if (!candle || candle.time !== bucketTime) { // New candle return { time: bucketTime, open: trade.price, high: trade.price, low: trade.price, close: trade.price, volume: trade.quantity, }; } // Update existing candle return { ...candle, high: Math.max(candle.high, trade.price), low: Math.min(candle.low, trade.price), close: trade.price, volume: candle.volume + trade.quantity, }; }
The bucket calculation is critical. For standard intervals (1m, 5m, 1h), it is straightforward integer division. For non-standard intervals or daily candles aligned to a specific timezone, the maths gets more involved.
The partial candle problem
The current candle is always partial - it represents the period that has not yet closed. This creates a few issues:
-
It must be visually distinct - Many charting libraries handle this automatically, but if yours does not, render the current candle with a slightly different style or opacity.
-
It gets replaced, not appended - Each trade updates the current candle in place. If you append instead of replace, you get duplicate candles.
-
It finalises on a time boundary - When the clock crosses the interval boundary, the current candle becomes final and a new partial candle begins.
function updateCandleSeries( series: Candle[], trade: Trade, intervalMs: number ): Candle[] { const bucketTime = Math.floor(trade.timestamp / intervalMs) * intervalMs; const lastCandle = series[series.length - 1]; if (lastCandle && lastCandle.time === bucketTime) { // Update the last candle const updated = [...series]; updated[updated.length - 1] = aggregateTrade(lastCandle, trade, intervalMs); return updated; } // New candle period - append const newCandle = aggregateTrade(null, trade, intervalMs); return [...series, newCandle]; }
In practice, you should avoid copying the entire array on every trade. Use a mutable data structure internally and only trigger a chart update at a controlled rate.
Bridging historical and live data
There is always a gap between the last historical candle and the first live trade. If you fetch candles and then connect to the trade stream, trades that occurred during the fetch are lost.
The robust approach:
- Connect to the WebSocket trade stream first and start buffering
- Fetch historical candles
- Replay the buffered trades against the candle series
- Switch to live processing
async function initChart(symbol: string, interval: string) { const buffer: Trade[] = []; const intervalMs = parseInterval(interval); // Start buffering trades immediately const unsubscribe = subscribeToTrades(symbol, (trade) => { buffer.push(trade); }); // Fetch historical data const candles = await fetchCandles(symbol, interval, 500); // Replay buffered trades let series = candles; for (const trade of buffer) { series = updateCandleSeries(series, trade, intervalMs); } // Switch to live updates unsubscribe(); subscribeToTrades(symbol, (trade) => { series = updateCandleSeries(series, trade, intervalMs); renderChart(series); }); renderChart(series); }
This eliminates the gap. Without it, you may see a jump in price between the last historical candle and the first live update.
Throttling chart updates
During active markets, trades can arrive hundreds of times per second. Re-rendering the chart on every trade is wasteful and causes frame drops.
Buffer trades and flush to the chart on requestAnimationFrame:
function createThrottledUpdater( chart: ChartApi, intervalMs: number ) { let series: Candle[] = []; let pendingTrades: Trade[] = []; let rafId: number | null = null; function processTrades() { for (const trade of pendingTrades) { series = updateCandleSeries(series, trade, intervalMs); } pendingTrades = []; chart.update(series[series.length - 1]); rafId = null; } return { addTrade(trade: Trade) { pendingTrades.push(trade); if (!rafId) { rafId = requestAnimationFrame(processTrades); } }, setSeries(initial: Candle[]) { series = initial; chart.setData(series); }, }; }
This collapses all trades within a single frame into one chart update. The visual result is identical, but the CPU cost drops dramatically.
Handling interval switches
When the user switches from 1-minute candles to 1-hour candles, you need to:
- Cancel the current trade subscription (or reuse it)
- Fetch new historical data for the new interval
- Reset the aggregation state
- Clear the chart and render the new series
The trade stream itself does not change - raw trades are the same regardless of the candle interval. Only the aggregation logic changes. This means you can keep the WebSocket connection alive and just swap the aggregation function.
function switchInterval(newInterval: string) { const newIntervalMs = parseInterval(newInterval); // Fetch new historical candles fetchCandles(symbol, newInterval, 500).then((candles) => { updater.setSeries(candles); // Aggregation now uses the new interval currentIntervalMs = newIntervalMs; }); }
Edge cases that will bite you
Trades arriving out of order - WebSocket messages are not guaranteed to arrive in timestamp order. If a trade arrives with a timestamp earlier than the current candle's last trade, you need to decide whether to re-aggregate or ignore it. Most charting implementations ignore it, which is acceptable for display purposes.
Daylight saving transitions - Daily candles that are aligned to a specific timezone will shift by an hour twice a year. Use a timezone-aware date library for daily and weekly candle alignment.
Exchange downtime - If the exchange goes offline, there will be gaps in the candle series. Your chart should handle missing candles gracefully rather than connecting them with a line.
Volume spikes - A single large trade can make the volume bar for one candle dwarf all others, compressing the volume chart into uselessness. Consider logarithmic scaling or a separate volume normalisation.
Putting it together
The full pipeline from trade to pixel involves more moving parts than most developers expect. The individual steps are straightforward, but the coordination between historical data loading, live trade aggregation, throttled rendering, and interval switching requires careful state management. Build each stage independently, test it with recorded trade data, and compose them into the final pipeline.
