BLOG · #Engineering

Charts in Flutter: custom painting that holds 60fps

A candlestick chart is the one screen where the widget tree is the wrong tool. Building it well comes down to three moves: paint instead of compose, split static and live into separate layers, and make shouldRepaint tell the truth.

Also in 中文. Flutter implementation notes for Trading app architecture.

Most Flutter performance advice is about the UI thread: fewer rebuilds, smaller subtrees. A chart moves the problem to the raster thread, where the question is not how many widgets you built but how much work you asked the GPU to do, and how often.

Don’t compose a chart out of widgets

The instinct is to make each candle a widget. Six hundred candles then become six hundred widgets to build, lay out, and paint, plus their parents — and every pan gesture redoes all of it.

A chart is one drawing, not many objects. Use a single CustomPainter and draw into a canvas. You give up the framework’s layout and hit-testing for that region, and in exchange you get a cost proportional to pixels rather than to elements, plus complete control over what happens per frame.

The widget tree remains the right tool for the toolbar, the legend, and the crosshair readout. It is the wrong tool for the series itself.

shouldRepaint is the whole game

This is the method that decides whether your chart is cheap or ruinous, and returning true — the safe-looking answer — means repainting on every frame forever.

class CandlePainter extends CustomPainter {
  CandlePainter({required this.series, required this.viewport, required this.revision});
  final CandleSeries series;
  final Viewport viewport;   // pan/zoom
  final int revision;        // bumped when series data changes

  @override
  bool shouldRepaint(CandlePainter old) =>
      old.revision != revision || old.viewport != viewport;
}

Compare a cheap version counter rather than the data itself. Deep-comparing a few thousand candles on every frame is its own performance problem, and identity comparison silently fails the moment you mutate a list in place. A monotonic revision that you bump on every data change is unambiguous and free.

Split static from live

Within one chart, different parts change at wildly different rates. Grid lines, axis labels, and completed candles change when you pan or when a bar closes. The last price line, the crosshair, and the in-progress candle change constantly.

Painting them together means the live element forces a repaint of everything.

So stack two painters, each in its own RepaintBoundary:

Stack(children: const [
  RepaintBoundary(child: CustomPaint(painter: StaticChartPainter(...))), // grid, closed candles
  RepaintBoundary(child: CustomPaint(painter: LivePainter(...))),        // last price, crosshair
])

Now a tick repaints a layer containing a couple of lines. The expensive layer — thousands of candles and text labels — is cached as a texture and composited, not redrawn.

This is the single highest-leverage change in most chart implementations, and it follows the same principle as scoping rebuilds on the UI thread: make the invalidated region match the thing that changed.

Don’t allocate inside paint()

paint() may run 60 times a second. Everything created there becomes garbage at that rate, and GC pauses are precisely the tail-latency problem you are trying to avoid.

Hoist Paint objects to fields. Reuse Path objects with reset() instead of constructing new ones. Precompute your data-to-pixel transform once per paint rather than per candle. And for large point counts, prefer the batch primitives — drawRawPoints, drawVertices, or a single Path — over thousands of individual drawLine calls, since each canvas call carries overhead that dominates when the shape is tiny.

Text is the hidden cost

Axis labels and price scales look free and are not: laying out text is one of the more expensive operations in a paint pass, and doing it for forty labels every frame will show up in your raster timings.

Cache TextPainter instances keyed by the string, and rebuild them only when the label set changes. Since price and time labels repeat heavily across frames, a small map gets you most of the way.

Interaction: transform the canvas, not the data

Pan and zoom should change a viewport (an offset and a scale) that you apply to the canvas, not rewrite your data:

canvas.save();
canvas.translate(-viewport.offsetX, 0);
canvas.scale(viewport.scaleX, 1);
// draw in data space
canvas.restore();

Recomputing pixel positions for every candle on every gesture frame is the common cause of chart panning that feels heavy. Keep the model in data coordinates and let the canvas transform do the work.

Hit testing runs the other way: convert the pointer position into data coordinates and binary-search the series for the nearest bar. That is a log-n lookup per gesture event instead of a scan.

Shader compilation, briefly

Historically, the first frame that used a new shader could compile it on the spot and drop that frame — the classic “janks once, then never again” bug that only reproduces on a fresh install. Impeller — as of Flutter 3.47 (August 2026) the only renderer on iOS and the default on Android API 29+ — compiles shaders ahead of time and removes this class of stutter. Older Android devices still fall back to the OpenGL/Skia path, so if you support them, test a fresh install there.

The practical guidance is short: stay on Impeller, and keep effects conventional. Exotic blend modes, large blurs, and heavy Opacity over complex subtrees are still expensive on any backend, because they force offscreen render passes. Opacity around a big subtree is the one most often reached for by accident — prefer opacity baked into a Paint color when you are painting anyway.

Measure the raster thread

Open the DevTools performance view and watch the two timelines separately. If UI frames are fine and raster frames are long, the fix is in this article: fewer layers, smaller invalidated regions, cheaper paint operations. If UI frames are long, your problem is upstream in the widget tree and belongs to the state article.

Test with a realistic series — a few thousand bars — on a mid-range device. A chart that is smooth with 50 candles on a flagship tells you nothing about the case that matters.

When to just use a library

If you need a standard line or bar chart that updates a few times a minute, a charting package is the right answer and everything above is premature.

Write your own when you need behavior the library does not have: synchronized indicator panes, custom drawing tools, precise crosshair semantics, tens of thousands of points, or ticking updates at frame rate. Those requirements are what justify owning a painter — not the belief that custom code will be faster in general.

References

API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).


Previous: Channels, Pigeon, and FFI · Next: Offline storage and local search