Charts that perform
Building a trading platform means rendering thousands of data points in real time while keeping the browser responsive. I've burned through enough performance budgings to know that charts that perform aren't about fancy animations or beautiful colors—they're about choosing the right tool, updating data efficiently, and knowing when to throttle. In this article, I'll share what we learned building high-frequency trading charts at scale, and how you can apply these patterns to your dashboards today.
Pick the right library — Highcharts for finance features; Recharts for simple dashboards
Not all chart libraries are created equal. When we started our trading platform, we spent two weeks comparing Highcharts, Recharts, Chart.js, and Nivo. The decision came down to what we needed to do with the charts, not how they looked.
Highcharts won for our trading UI because of its financial-specific features: built-in date range selectors, candlestick rendering optimizations, annotation layers, and crosshair interactions that feel native. More importantly, its architecture supports efficient data updates—critical when you're pushing 50 price ticks per second to hundreds of concurrent traders. The library was designed for Bloomberg-style terminals, not marketing dashboards.
But I won't pretend Highcharts is always the right answer. For our internal CRM analytics dashboard, we ditched it and moved to Recharts. Why? We didn't need financial primitives. We were plotting customer churn, sales funnels, and monthly revenue. Recharts is smaller, has zero licensing costs, composes cleanly with React, and was fast enough for monthly aggregated data. The overhead of Highcharts would have been waste.
Here's how we think about it now:
- Highcharts: Real-time data, complex interactions, financial instruments, large datasets that need server-side aggregation.
- Recharts: Static or slow-moving data, simple chart types, tight integration with React components, cost-conscious projects.
- Chart.js: Simple dashboards where you control the DOM yourself, no React wrapper needed.
- Nivo: Beautiful, opinionated charts; slower rendering than Recharts but richer defaults.
Choose based on your update frequency and feature requirements, not aesthetics. Your team will thank you six months later when you're not fighting the library's assumptions.
Update, do not re-create — Update series data instead of re-rendering the whole chart
This is where most teams stumble. I've seen codebases where every new price tick triggers a full chart re-render. The browser paints the entire SVG tree, recalculates axes, redraws legends, and re-animates transitions. On a 1-minute candlestick chart with 1000 bars, this tanks your frame rate to 20fps.
The fix is simple in theory, messy in practice: use the chart library's update() method instead of replacing props and letting React re-render.
With Highcharts, this looks like:
// Bad: re-renders the entire chart
const [series, setSeries] = useState(initialSeries);
useEffect(() => {
const unsubscribe = subscribeToPrice((newPrice) => {
setSeries(prevSeries => {
const updated = [...prevSeries];
updated[0].data.push(newPrice);
return updated;
});
});
return unsubscribe;
}, []);
return <HighchartsReact highcharts={Highcharts} options={options} series={series} />;Better: use the chart ref to update directly
const chartRef = useRef(null);
useEffect(() => {
const unsubscribe = subscribeToPrice((newPrice) => {
if (chartRef.current?.chart) {
const chart = chartRef.current.chart;
const series = chart.series[0];
// Update the last point or add a new one
if (series.data.length > MAX_POINTS) {
series.removePoint(0, false);
}
series.addPoint(newPrice, true, false);
}
});
return unsubscribe;
}, []);
return (
<HighchartsReact
ref={chartRef}
highcharts={Highcharts}
options={options}
/>
);The second approach calls addPoint() directly on the series object. Highcharts updates the DOM surgically—only the new point and affected axis labels get touched. No re-animation of existing bars, no recalculation of the entire domain.
On our trading platform, this single change dropped frame drop frequency from 15% to under 2%. Users stopped complaining about jank.
For Recharts, the pattern is different because Recharts re-renders on prop changes by design. Instead, you batch updates into fewer re-renders (see next section) or use a ref-based library like plotly.js that supports direct mutations.
Throttle live data — Batch updates to a few per second. Eyes cannot read faster
Human eyes refresh at roughly 60fps, which sounds like "I should update my chart 60 times per second." That's wrong. Your trading API might fire 100 price ticks per second. Your WebSocket might be sending 500 chat messages per second. Rendering all of them is waste.
We use a throttle pattern: batch incoming updates and flush to the chart every 200–500ms. This keeps the UI responsive while staying accurate enough for traders.
const useThrottledChartUpdates = (chart, interval = 250) => {
const pendingUpdates = useRef([]);
const flushTimer = useRef(null);
const scheduleUpdate = useCallback((update) => {
pendingUpdates.current.push(update);
if (!flushTimer.current) {
flushTimer.current = setTimeout(() => {
pendingUpdates.current.forEach(update => {
if (chart?.chart) {
const series = chart.chart.series[0];
series.addPoint(update, false); // false = don't redraw yet
}
});
// Redraw once after all updates applied
if (chart?.chart) {
chart.chart.redraw();
}
pendingUpdates.current = [];
flushTimer.current = null;
}, interval);
}
}, [chart, interval]);
useEffect(() => {
return () => {
if (flushTimer.current) {
clearTimeout(flushTimer.current);
}
};
}, []);
return scheduleUpdate;
};
// Usage
const chartRef = useRef(null);
const scheduleUpdate = useThrottledChartUpdates(chartRef, 250);
useEffect(() => {
const unsubscribe = subscribeToPrice(price => {
scheduleUpdate(price);
});
return unsubscribe;
}, [scheduleUpdate]);Notice addPoint(update, false)—the second argument is redraw. We're telling Highcharts not to redraw after each point. Then we call redraw() once after batching all pending updates. This coalesces 100 WebSocket messages into 4–5 redraws per second.
The interval is tunable. 250ms works for most dashboards. Trading desks might use 100ms. Static reports can go 1000ms. Measure your actual use case.
Custom tools with SVG — We built an arrow marker tool on top of Highcharts using SVG for user annotations
Traders want to annotate charts. They draw trend lines, mark resistance levels, add flags. Most chart libraries ship with basic drawing tools, but they're slow or limited.
We built a custom annotation layer on top of Highcharts using SVG. The key insight: annotations live in a separate SVG overlay, not inside the Highcharts SVG. This means updating prices doesn't re-render annotations, and users can draw without blocking the chart.
Here's a simplified arrow marker tool:
const ArrowMarkerTool = ({ chartContainer, onAnnotation }) => {
const svgRef = useRef(null);
const [isDrawing, setIsDrawing] = useState(false);
const [start, setStart] = useState(null);
useEffect(() => {
// Create overlay SVG above the chart
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('style', 'position: absolute; top: 0; left: 0; cursor: crosshair;');
const rect = chartContainer.getBoundingClientRect();
svg.setAttribute('width', rect.width);
svg.setAttribute('height', rect.height);
chartContainer.style.position = 'relative';
chartContainer.appendChild(svg);
svgRef.current = svg;
return () => {
if (svg.parentNode) svg.parentNode.removeChild(svg);
};
}, [chartContainer]);
const handleMouseDown = (e) => {
setIsDrawing(true);
setStart({ x: e.clientX, y: e.clientY });
};
const handleMouseMove = (e) => {
if (!isDrawing || !start || !svgRef.current) return;
// Clear previous preview
Array.from(svgRef.current.querySelectorAll('.preview')).forEach(el => el.remove());
const end = { x: e.clientX, y: e.clientY };
drawArrow(svgRef.current, start, end, 'preview');
};
const handleMouseUp = (e) => {
if (!isDrawing || !start || !svgRef.current) return;
const end = { x: e.clientX, y: e.clientY };
// Draw final arrow
const arrowId = `arrow-${Date.now()}`;
drawArrow(svgRef.current, start, end, arrowId);
onAnnotation({ id: arrowId, type: 'arrow', start, end });
setIsDrawing(false);
setStart(null);
};
useEffect(() => {
if (!svgRef.current) return;
svgRef.current.addEventListener('mousedown', handleMouseDown);
svgRef.current.addEventListener('mousemove', handleMouseMove);
svgRef.current.addEventListener('mouseup', handleMouseUp);
return () => {
svgRef.current?.removeEventListener('mousedown', handleMouseDown);
svgRef.current?.removeEventListener('mousemove', handleMouseMove);
svgRef.current?.removeEventListener('mouseup', handleMouseUp);
};
}, [isDrawing, start]);
return null;
};
const drawArrow = (svg, start, end, className) => {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('class', className);
// Arrow line
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', start.x);
line.setAttribute('y1', start.y);
line.setAttribute('x2', end.x);
line.setAttribute('y2', end.y);
line.setAttribute('stroke', '#ff0000');
line.setAttribute('stroke-width', '2');
// Arrowhead (marker)
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
const angle = Math.atan2(end.y - start.y, end.x - start.x);
const headlen = 15;
const points = [
end.x + ',' + end.y,
(end.x - headlen * Math.cos(angle - Math.PI / 6)) + ',' + (end.y - headlen * Math.sin(angle - Math.PI / 6)),
(end.x - headlen * Math.cos(angle + Math.PI / 6)) + ',' + (end.y - headlen * Math.sin(angle + Math.PI / 6))
].join(' ');
marker.setAttribute('points', points);
marker.setAttribute('fill', '#ff0000');
g.appendChild(line);
g.appendChild(marker);
svg.appendChild(g);
};
// Usage
<ArrowMarkerTool
chartContainer={chartContainerRef.current}
onAnnotation={(ann) => saveAnnotation(ann)}
/>This pattern scales. You can build trendline tools, text labels, and range selectors the same way. The SVG overlay stays lightweight because it's not managing price data or axis scales. When the chart updates, annotations stay put. When users draw, the price chart keeps streaming.
Common mistakes
- Recreating the chart on every prop change. Use refs and the chart's update methods. Re-rendering is for data changes you can't batch.
- Ignoring memory leaks in WebSocket subscriptions. Every
addPoint()keeps data in RAM. Cap your visible points to something reasonable (500–5000 depending on your browser target) and trim old data withremovePoint(). - Not throttling incoming events. If you're receiving 100 updates per second and rendering all of them, you've already lost. Batch first, measure second.
- Treating all chart libraries as interchangeable. Recharts and Highcharts have opposite strengths. Pick the right one for your use case the first time.
- Building annotations inside the chart library. Your charting library isn't optimized for drawing. Use an overlay SVG or Canvas layer instead.
Key takeaways
- Choose your chart library based on update frequency and feature requirements. Highcharts for trading, Recharts for dashboards.
- Update chart data directly via the library's mutation API, not React props.
addPoint()andupdate()beat re-renders every time. - Throttle incoming live data to 4–5 updates per second. Humans can't perceive faster, and your browser can't keep up anyway.
- Build custom tools on overlay SVG or Canvas, not inside the charting library. Keep concerns separated.
- Measure actual frame rates and memory usage in production before optimizing. Your bottleneck might surprise you.






Comments
Loading comments…