Performance & Smoothness
Patterns that keep the chart at 60 fps during pan, zoom, replay, and high-frequency data ingest. The do/don't list for trading-grade smoothness.
The engine paints at native speed — most performance complaints we see come from how data is fed in, not from the renderer itself. This page lists the patterns that keep things smooth, the anti-patterns that don't, and the exact symptoms each one causes so you can diagnose your own UI in under a minute.
The mental model
Two clocks drive the chart:
- Data clock — your websocket / REST stream delivering ticks, depth snapshots, trades.
- Render clock — the browser's vsync, ~16.6 ms on a 60 Hz display.
Smoothness happens when the first clock never blocks the second. Everything below is a consequence of that.
✓ Things to do
Batch your tick feed
Exchange websockets often deliver 50-200 messages per second. If you call updateLastKline 200 times in 16 ms, you're paying the cost 200× per frame. Coalesce on your side:
let pending = null
let raf = 0
ws.onmessage = (e) => {
pending = mergeTick(pending, JSON.parse(e.data))
if (raf) return
raf = requestAnimationFrame(() => {
chart.updateLastKline(
pending.ts, pending.o, pending.h, pending.l, pending.c, pending.v,
)
pending = null
raf = 0
})
}
Effective result: at most one chart call per frame, no matter how chatty the feed.
Use typed arrays for bulk loads
setKlines(t, o, h, l, c, v) accepts Float64Array or number[]. Typed arrays skip a copy step. On a 50 000-bar initial load that's the difference between an imperceptible 4 ms and a visible 40 ms hiccup.
const t = new Float64Array(rows.length)
const o = new Float64Array(rows.length)
// ... fill in one tight loop, then:
chart.setKlines(t, o, h, l, c, v)
Call prefetchEngine() at app boot
The engine module is fetched and compiled on demand. Calling prefetchEngine() as early as your app can — typically in main.ts or a top-level <script setup> — moves that cost off your chart's mount frame.
import { prefetchEngine } from '@mrd/chart-engine'
prefetchEngine() // fire-and-forget
No await, no error handling — it's idempotent and gracefully no-ops on a slow network.
Match the canvas resolution to the device
Pass the canvas element directly, let createChartBridge measure the device pixel ratio once, and call chart.resize() only when the parent element's size actually changes — not on every window.resize event.
const ro = new ResizeObserver(() => chart.resize())
ro.observe(canvas.parentElement)
// Cleanup on unmount:
onBeforeUnmount(() => { ro.disconnect(); chart.destroy() })
ResizeObserver fires only on real layout changes; window.resize fires once per orientation change AND once per scrollbar appearance AND once per zoom change. Use the former.
Pause when the tab is hidden
The browser already throttles requestAnimationFrame in background tabs, but indicator math and JSON parsing don't pause automatically. Hook into visibilitychange:
document.addEventListener('visibilitychange', () => {
if (document.hidden) chart.pause()
else chart.resume()
})
On a long-lived trading dashboard this is the single biggest battery / CPU win — pausing a backgrounded chart drops its main-thread work to zero.
Destroy on unmount, every time
Mounting a chart inside a route-component without calling destroy() on unmount leaks: the render loop keeps running, event listeners stay attached, the canvas keeps painting at 60 fps invisibly. Three route changes in and the tab uses 60 % CPU for "nothing".
// Vue
onBeforeUnmount(() => chart.destroy())
// React
useEffect(() => () => chart.destroy(), [])
destroy() releases everything — render loop, listeners, internal buffers. The chart instance is unusable after destroy(); create a new one if you need to mount again.
Send sorted, deduped data
If your data source can emit duplicate or out-of-order timestamps (some exchange websockets do during reconnects), dedupe in your handler before calling appendKline. The engine assumes timestamps are strictly increasing; feeding it duplicates produces undefined rendering.
✗ Things to avoid
Don't call setKlines on every tick
setKlines is the full-reload method — it resets the dataset, the viewport, and the cursor state. On a 30 000-bar dataset it does real work. For tick updates use updateLastKline; for closed bars use appendKline.
Symptom: viewport snaps back to "latest 120 candles" on every tick, can't pan back.
Don't JSON.parse the tooltip payload inside a framework render
Tooltip fires up to 60× / sec. If your callback triggers a React setState or Vue ref write that re-renders an ancestor component, you're paying a full reconcile per frame.
// ❌ Bad — every hover frame triggers a Vue/React reconcile
chart.onTooltip((raw) => { tooltipData.value = JSON.parse(raw) })
// ✓ Good — write directly into DOM, framework never knows
chart.onTooltip((raw, sx, sy) => {
if (!raw) { tipEl.style.display = 'none'; return }
const p = JSON.parse(raw)
tipEl.textContent = `${p.kline.close.toFixed(2)}`
tipEl.style.cssText += `;display:block;left:${sx + 12}px;top:${sy + 12}px`
})
Symptom: smooth chart, janky hover. CPU profile shows your component's render function dominating.
Don't run chart.resize() in a tight loop
resize() recomputes layout, regenerates internal buffers, and forces a repaint. Calling it on window.resize (which fires ~10× during a drag-resize) costs frames.
Symptom: visible flicker / freeze while the user resizes the browser. Use ResizeObserver instead, debounced if the parent itself is animating.
Don't append depth columns slower than the depth-snapshot rate
The orderbook heatmap holds one column per snapshot in a ring buffer. If your snapshots arrive at 10 Hz but you only call appendHeatmapColumn at 1 Hz, you've thrown away 90 % of the depth resolution and the heatmap looks chunky.
If your data clock is slower than 10 Hz, that's fine — the heatmap was designed for fast feeds. Just don't artificially throttle on your side.
Don't repeatedly toggle indicators in user code
enableRsi(true) / enableRsi(false) allocates and frees the indicator's series buffer. Toggling at 60 Hz to "animate" the indicator on/off thrashes allocations. If you need a fade, fade the canvas's opacity in your CSS layer — the indicator itself stays on.
Don't compute drawings from inside an animation loop
addTrendline / addHorizontal are persistent — they live in the engine until you remove them. Calling addTrendline on every frame stacks N drawings per second.
Symptom: chart slows over time, scrolling reveals hundreds of overlapping lines.
Don't init two charts on one canvas
createChartBridge(canvas) takes ownership of the canvas (event listeners, transform matrix, the works). Initialising twice without destroying the first instance produces two render loops fighting over the same pixels.
Symptom: hover events fire twice, drawings appear at wrong positions, frame timing oscillates.
Don't keep the parent dialog mounted while hidden
A common React / Vue pattern is to render the chart inside a <Modal> and toggle the modal's display: none instead of unmounting. The chart's render loop keeps running behind the modal, wasting CPU.
Either unmount the chart when the modal closes (clean), or call chart.pause() while hidden and chart.resume() when shown (lazy).
Measuring it
Chrome DevTools → Performance → record 5 seconds of chart pan. The frames pane should show consistent 16-ms bars with no red frames. If you see red:
- Long yellow (Scripting) blocks → your data feed is too chatty, batch as shown above.
- Long purple (Rendering) blocks → too many simultaneous indicators or drawings, prune what's not needed.
- Long green (Painting) blocks → the canvas is too large, lower the device pixel ratio if your display is over 2×.
Next
- Common Pitfalls — the catalogue of bugs we see most often in production support tickets, with the one-line fix for each.
- Chart Instance —
start,stop,pause,resume,destroy— the lifecycle methods used in every recipe above. - Events & Tooltips — tooltip throttling done right.