Tutorial
Candlestick chart in JavaScript with Lightweight Charts and live data
Most candlestick tutorials stop at a static CSV. A chart people watch needs history for context and a last candle that moves, and the join between the two is where the bugs are.
On this page
Key takeaways
- Load history once with `series.setData()`, then change only the newest candle with `series.update()`: the same time replaces it, a later time appends a new one.
- Lightweight Charts takes time in Unix seconds, while TickerLayer bars and frames use milliseconds, so divide by 1,000.
- Bucket each trade with `Math.floor(ts / 60000) * 60`: the same minute moves high, low and close, a new minute opens a new candle.
- Open the stream before you load history and replay the buffered trades afterwards, or the join between the two loses trades.
- The library is free under the Apache 2.0 license, which asks for visible attribution; its built-in attribution logo covers that.
To draw a live candlestick chart in JavaScript, load historical OHLC bars once over REST, hand them to Lightweight Charts with series.setData(), then fold each streamed trade into the newest candle with series.update(). A trade inside the current minute moves that candle's high, low and close; the first trade of a new minute opens the next candle.
Lightweight Charts is an open-source library built for exactly this kind of financial time series, where all the change happens at the right edge. The live half runs on the stream described in the stock WebSocket API guide. We chart BTCUSD because crypto trades around the clock, so you can test at any hour. If the four prices in a candle are new to you, OHLC bars explained covers what each one means.
- REST barsGET /crypto/agg, 1 minute
- setData()history, oldest first
- Stream tradescrypto.trades
- Bucket by minutefloor(ts / 60,000)
- update()the newest candle only
What you are building
Two files: a 93-line Node.js server and a 72-line HTML page. The server exists for one reason, the key. Browser REST calls are only accepted from approved origins, and the stream URL carries the key in ?apiKey=, so the page asks your server for bars and trades and never holds a key itself.
- Browser pageLightweight Charts from a CDN, an EventSource for trades, the bucketing logic.
index.html - Your server/bars proxies REST with a 5-second cache; /stream relays trades from one upstream socket as Server-Sent Events.
server.mjs - TickerLayerREST aggregates for history, the WebSocket stream for live trades.
API
Candles from ticks: the bucketing rule
bucket = floor(ts ÷ 60,000) × 60
- ts
- Trade time in Unix milliseconds, as the stream sends it.
- bucket
- Start of the trade's minute in Unix seconds, the unit Lightweight Charts expects.
| The trade falls in | What the code does | Chart call |
|---|---|---|
| The current candle's minute | high = max, low = min, and close = price if this is the newest trade so far. | series.update() with the same time replaces the candle. |
| A later minute | Starts a new candle with open, high, low and close all at the trade price. | series.update() with a later time appends it. |
| An earlier minute | Skips it: that candle is already drawn and finished. | None. Rewriting an older bar needs the historicalUpdate flag. |
BTCUSD, 1-minute bars
Load historical bars over REST
History comes from one request: GET /crypto/agg/BTCUSD/1/minute/{from}/{to}?sort=asc&limit=5000. from and to are UTC dates, both inclusive, so yesterday plus today is at most 2,880 one-minute bars, well under the 5,000 a page can hold. The default sort is newest first; setData() wants oldest first, so sort=asc is not optional.
The response, trimmed to its newest bar
{
"symbol": "BTCUSD",
"results_count": 2093,1
"total_count": 2093,
"results": [
{ "o": 82902.58, "h": 83000, "l": 82902.58, "c": 82999.96, "v": 76.60458, "t": 1790592720000 }2
],
"next_offset": null3
}
results_countBars in this page: all 1,440 minutes of yesterday plus 653 of today.tBar start in Unix milliseconds. Divide by 1,000 for the chart.next_offsetnullmeans there are no more pages. Otherwise pass it back asoffset.
The caption is the important part. The request landed 23 seconds into the 10:52 minute, and the newest bar was that minute, still forming. So the code treats the last REST bar as the live candle and keeps updating it, instead of starting a fresh candle on the first streamed trade. Pagination and the other intervals (5 and 15 minutes, 1 and 4 hours, 1 day) are in the aggregates reference, and the historical data guide covers loading years of bars.
The server: one key, two routes
// npm install ws (Node.js 18 or newer)
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import WebSocket from "ws";
const KEY = process.env.TICKERLAYER_API_KEY;
const REST = "https://api.tickerlayer.com";
const STREAM = "wss://stream.tickerlayer.com/?apiKey=" + encodeURIComponent(KEY);
const SYMBOL = "BTCUSD";
const PORT = 8080;
// ---- Historical bars: yesterday and today, 1-minute, oldest first ----
let cache = { at: 0, bars: null };
async function loadBars() {
if (cache.bars && Date.now() - cache.at < 5_000) return cache.bars; // many viewers, one request
const now = new Date();
const to = now.toISOString().slice(0, 10);
const from = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10);
const url = `${REST}/crypto/agg/${SYMBOL}/1/minute/${from}/${to}?sort=asc&limit=5000`;
const res = await fetch(url, { headers: { "x-api-key": KEY } });
if (!res.ok) throw new Error(`bars request failed with HTTP ${res.status}`);
const body = await res.json();
// TickerLayer bars use t in Unix milliseconds; the chart wants seconds.
const bars = body.results.map((b) => ({ time: b.t / 1000, open: b.o, high: b.h, low: b.l, close: b.c }));
cache = { at: Date.now(), bars };
return bars;
}
// ---- Live trades: one upstream socket, fanned out to every open page ----
const pages = new Set(); // open Server-Sent Events responses
let upstream = null;
let backoffMs = 1_000;
function connectUpstream() {
const ws = new WebSocket(STREAM, { perMessageDeflate: false });
let readyAt = 0;
upstream = ws;
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === "system" && msg.event === "ready") {
readyAt = Date.now();
// snapshot: false, because the REST bars already contain everything up to now.
ws.send(JSON.stringify({ action: "subscribe", channels: ["crypto.trades"], symbols: [SYMBOL], snapshot: false }));
} else if (msg.type === "trade" && msg.symbol === SYMBOL) {
const line = `data: ${JSON.stringify({ price: Number(msg.price), ts: msg.ts })}\n\n`;
for (const res of pages) res.write(line);
} else if (msg.type === "error") {
console.error(`stream error ${msg.code}: ${msg.message}`);
}
});
ws.on("error", (err) => console.error(`stream socket error: ${err.message}`));
ws.on("close", (code) => {
upstream = null;
if (pages.size === 0) return; // nobody is watching: stay disconnected
if (readyAt && Date.now() - readyAt > 30_000) backoffMs = 1_000; // it was healthy
const wait = Math.round(backoffMs / 2 + Math.random() * (backoffMs / 2));
backoffMs = Math.min(backoffMs * 2, 30_000);
console.log(`stream closed with ${code}; reconnecting in ${wait} ms`);
setTimeout(() => { if (!upstream && pages.size > 0) connectUpstream(); }, wait);
});
}
// ---- HTTP: the page, the bars and the trade stream ----
createServer(async (req, res) => {
try {
if (req.url === "/") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(await readFile(new URL("./index.html", import.meta.url)));
} else if (req.url === "/bars") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(await loadBars()));
} else if (req.url === "/stream") {
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform" });
res.write(": connected\n\n");
pages.add(res);
if (!upstream) connectUpstream();
req.on("close", () => {
pages.delete(res);
if (pages.size === 0) upstream?.close(1000);
});
} else {
res.writeHead(404).end();
}
} catch (err) {
console.error(err);
if (!res.headersSent) res.writeHead(502, { "Content-Type": "text/plain" });
res.end(String(err.message));
}
}).listen(PORT, () => console.log(`open http://localhost:${PORT}`));
// Comment lines keep idle proxies from closing the event stream.
setInterval(() => { for (const res of pages) res.write(": keep-alive\n\n"); }, 20_000);Three choices keep it cheap. The upstream socket opens only when the first page connects and closes when the last one leaves, so an idle chart holds no connection. Every open tab shares that one socket, so a hundred viewers still cost one connection. And /bars caches for five seconds: a burst of page loads costs one REST request instead of one each, at the price of a candle that can miss up to five seconds of trades before the stream takes over.
The subscribe message sets snapshot: false. By default the stream replays the last recent trade on subscribe so a screen has something to show, but here REST already supplied everything up to now, and a replayed trade would only be counted twice.
The page: history first, then live updates
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>BTCUSD, 1-minute candles</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lightweight-charts.standalone.production.js"></script>
<style>
html, body { margin: 0; height: 100%; background: #0f1115; color: #d1d4dc; font: 14px system-ui, sans-serif; }
#chart { position: absolute; inset: 0; }
#status { position: absolute; top: 8px; left: 12px; z-index: 2; }
</style>
</head>
<body>
<div id="status">Loading bars...</div>
<div id="chart"></div>
<script>
const { createChart, CandlestickSeries } = LightweightCharts;
const status = document.getElementById("status");
const chart = createChart(document.getElementById("chart"), {
autoSize: true,
layout: { background: { color: "#0f1115" }, textColor: "#d1d4dc" }, // attributionLogo stays on
grid: { vertLines: { color: "#1e222d" }, horzLines: { color: "#1e222d" } },
timeScale: { timeVisible: true, secondsVisible: false },
});
const series = chart.addSeries(CandlestickSeries, {
upColor: "#26a69a", downColor: "#ef5350", borderVisible: false,
wickUpColor: "#26a69a", wickDownColor: "#ef5350",
});
let candle = null; // the newest candle, the only one we ever change
let closeTs = 0; // ts of the trade that set candle.close
const early = []; // trades that arrive before the bars do
function applyTrade({ price, ts }) {
const time = Math.floor(ts / 60_000) * 60; // start of the trade's minute, in seconds
if (time < candle.time) return; // late trade for a finished candle: skip it
if (time > candle.time) {
candle = { time, open: price, high: price, low: price, close: price };
closeTs = ts;
} else {
candle.high = Math.max(candle.high, price);
candle.low = Math.min(candle.low, price);
if (ts >= closeTs) { candle.close = price; closeTs = ts; }
}
series.update({ ...candle });
}
// 1. Start listening first, so no trade falls between the history and the stream.
const events = new EventSource("/stream");
events.onmessage = (event) => {
const trade = JSON.parse(event.data);
if (candle) applyTrade(trade); else early.push(trade);
};
events.onerror = () => { status.textContent = "Stream interrupted, retrying..."; };
events.onopen = () => { if (candle) status.textContent = "BTCUSD, live"; };
// 2. Then load history, draw it and replay whatever arrived meanwhile.
fetch("/bars")
.then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); })
.then((bars) => {
if (bars.length === 0) throw new Error("no bars in range");
series.setData(bars);
candle = { ...bars[bars.length - 1] };
closeTs = candle.time * 1000;
early.splice(0).forEach(applyTrade);
status.textContent = "BTCUSD, live";
})
.catch((err) => { status.textContent = `Could not load bars: ${err.message}`; });
</script>
</body>
</html>The order of operations is the subtle part. The page opens the event stream before it requests the bars, and buffers any trade that arrives while the bars are in flight. Once setData() has drawn history, the buffer is replayed through the same applyTrade(). Do it the other way round and every trade between the REST response and the stream opening is lost, which shows up as a last candle whose high is wrong.
Run npm install ws, then TICKERLAYER_API_KEY=... node server.mjs, and open http://localhost:8080. You get two days of one-minute candles with the rightmost one moving in place. We ran the page in Chrome against real bars from this endpoint and a scripted trade feed, and tested the edge cases directly: a late trade for a finished minute is skipped, an out-of-order trade inside the minute updates high and low without moving the close, and the first trade of a new minute appends a candle.
Edge cases that break live candles
- Late trades at the boundaryTrades from an aggregated feed do not always arrive in time order. One stamped 10:52:59.9 can land after 10:53 has opened; skip it rather than reopen a drawn candle.
- The gap between history and streamOpen the stream first and buffer. Anything else leaves a hole exactly where the viewer is looking.
- Time zonesThe chart shows UTC. Format local time with
localization.timeFormatterandtimeScale.tickMarkFormatter, or shift each time by the offset. - Strings, not numbersCrypto frames send prices as strings. The server converts them once with
Number(); comparing strings would put "9" above "10". - Replayed tradesA snapshot trade is not a new execution. With history from REST, subscribe with
snapshot: false. - VolumeAdding a volume histogram means summing sizes, and there a buffered or replayed trade counts twice. Deduplicate before you sum.
US stocks: settled bars from stocks.agg
For a US stock there is a shortcut for completed candles. The stocks.agg channel pushes each OHLCV bar once it has settled, at 1m, 5m, 15m, 1h, 4h or 1d, with the same values as the REST aggregates. Pass each frame to series.update() with time: frame.ts / 1000 and the chart grows one finished candle at a time. The stock bars stream docs describe the contract.
{"action": "subscribe", "channels": ["stocks.agg"], "symbols": ["US:KO"], "interval": "1m", "session": "regular"}
{"type":"agg","channel":"stocks.agg","asset":"stocks","symbol":"US:KO","interval":"1m","session":"regular","o":88.48,"h":88.52,"l":88.46,"c":88.5,"v":41230,"n":312,"vw":88.4917,"ts":1789133400000}Two differences from the crypto version. Settled bars never include the minute in progress, so for a moving last candle you still fold stocks.trades into it the same way. And the channel never replays: bars that closed while you were disconnected come from GET /stocks/agg, the same REST call that loaded the history.
Lightweight Charts or Chart.js for candlesticks
| Feature | Lightweight Charts | Chart.js with a financial plugin |
|---|---|---|
| Candlesticks built in | ||
| Designed for updates at the right edge | ||
| Price and time scales tuned for markets | ||
| General charts (pie, radar, grouped bars) | ||
| License | Apache 2.0, attribution asked | MIT |
If the page is mostly a price chart, Lightweight Charts is the shorter path. If candles are one widget among many ordinary charts, staying on Chart.js with its financial plugin keeps one charting stack; the REST and streaming code above does not change. To put this chart inside a React app with the key safely on the server, the Next.js WebSocket tutorial builds the same relay as a route handler. The BTCUSD symbol page shows the instrument with a public delay.
Questions
Is Lightweight Charts free to use?
Yes. It is open source under the Apache 2.0 license, commercial use included. The license notice asks you to credit the authors with a link, which the chart's default attribution logo handles.
How do I update the last candle in Lightweight Charts?
Call series.update() with a bar whose time equals the last bar's time and it is replaced; a later time appends a new bar. Updating an older bar requires passing true as the second argument, historicalUpdate.
Does Lightweight Charts use seconds or milliseconds?
Seconds. Its UTCTimestamp is a Unix time in seconds, so divide millisecond timestamps from an API or Date.now() by 1,000.
Can Chart.js draw candlestick charts?
Not on its own. It needs a financial chart plugin that adds candlestick and OHLC chart types, plus a date adapter for the time axis.
How do I show local time instead of UTC in Lightweight Charts?
The chart displays timestamps as UTC. Either shift each time by your UTC offset before passing it in, or supply localization.timeFormatter and timeScale.tickMarkFormatter functions that format in local time.