Also in 中文. Flutter implementation notes for Trading app architecture; the principle behind it is in Real-time data pipelines.
Put 300 symbols on screen, each updating a few times a second, and the standard Flutter approach falls over. Not because Flutter is slow — because the standard approach rebuilds a subtree proportional to the screen on every change proportional to a single cell.
The fix is one idea applied ruthlessly: the rebuilt subtree should be as small as the thing that changed. Everything below is a consequence.
Why the ordinary way fails
The usual shape is a store holding a map of quotes, exposed to the tree, with the list watching it. One tick arrives, the store notifies, and the list rebuilds — 300 rows, each with a handful of widgets, so a thousand-plus build() calls for one number that moved.
At 60 fps you have 16.6 ms per frame for everything: build, layout, paint, raster. A few hundred ticks a second against a screen-sized rebuild will not fit, and the symptom is the worst kind — it’s fine in development with mock data and janky exactly when the market is interesting.
Note that this is not a criticism of any particular state management package. Provider, Bloc, Riverpod, and setState all behave correctly here; the problem is that the granularity was chosen wrong. You can build this badly with any of them, and well with most of them.
Rebuild only the leaf
Give each symbol its own listenable, and let the widget that displays it be the only thing subscribed.
class QuoteStore {
final _notifiers = <String, ValueNotifier<Quote>>{};
ValueListenable<Quote> listen(String symbol) =>
_notifiers.putIfAbsent(symbol, () => ValueNotifier(Quote.empty(symbol)));
// called from the feed, many times per second
void apply(Quote q) {
final n = _notifiers[q.symbol];
if (n == null) return; // nobody is showing it — do no work
if (n.value == q) return; // no visible change — do no work
n.value = q; // notifies exactly one subtree
}
}
class PriceCell extends StatelessWidget {
const PriceCell(this.store, this.symbol, {super.key});
final QuoteStore store;
final String symbol;
@override
Widget build(BuildContext context) => ValueListenableBuilder<Quote>(
valueListenable: store.listen(symbol),
builder: (_, q, __) => Text(q.last.toStringAsFixed(2)),
);
}
The row, the list, and the page never rebuild. A tick for AAPL rebuilds one Text. The cost of an update is now proportional to what changed rather than to what is on screen — which is the property you were after.
Two details in apply() do a lot of work. Skipping symbols nobody is displaying turns an off-screen subscription leak into a no-op, and the equality check absorbs the very common case of a message that carries no visible change.
Conflate onto the frame, not onto the tick
Rebuilding one leaf per tick is cheap, but 20 ticks per second per symbol is still 19 wasted rebuilds — the screen only shows 60 frames, and a human perceives far fewer.
So buffer incoming ticks and flush once per frame:
class FrameCoalescer {
FrameCoalescer(this._flush);
final void Function(Iterable<Quote>) _flush;
final _pending = <String, Quote>{}; // keyed: last one wins
bool _scheduled = false;
void add(Quote q) {
_pending[q.symbol] = q; // conflation, for free
if (_scheduled) return;
_scheduled = true;
SchedulerBinding.instance.scheduleFrameCallback((_) {
_scheduled = false;
final batch = _pending.values.toList(growable: false);
_pending.clear();
_flush(batch);
});
}
}
The map does the conflation: ten ticks for one symbol between two frames collapse to one, automatically, because a later write replaces an earlier one under the same key. This is the register case from the pipeline article expressed in nine lines.
Now your update rate is bounded by the frame rate no matter what the market does. A volatile open costs slightly more decoding and exactly the same rendering.
Keep the list itself cheap
Even with perfect leaf updates, the list can undo your work:
ListView.builderwithitemExtent. A known extent lets Flutter skip measuring children to compute scroll geometry — a large win for uniform rows.constconstructors everywhere they’re possible. Aconstwidget is skipped during rebuild instead of being reconstructed and compared.RepaintBoundaryaround independently-animating cells, so a flashing price cell doesn’t dirty the whole list’s layer. Use it deliberately: each boundary is a layer, and hundreds of them cost memory.- Hoist the notifier lookup. Calling
listen(symbol)insidebuild()is fine when it’s a cheap map read, but never create the notifier during build if creating it has side effects like subscribing upstream.
Subscribe from the widget lifecycle
Screen visibility should drive subscriptions, or you will pay for data nobody is looking at:
@override
void initState() {
super.initState();
_sub = feed.subscribe(widget.symbols); // reference-counted upstream
}
@override
void dispose() {
_sub.cancel(); // last listener → unsubscribe
super.dispose();
}
Reference-count in the feed so two widgets showing the same symbol produce one upstream subscription, and the subscription ends when the second one goes. Combine that with app lifecycle: on AppLifecycleState.paused, drop to a slow summary or stop entirely.
Measure the right thread
Flutter has two threads that matter, and they fail differently. The UI thread runs your Dart: build, layout, and the framework. The raster thread turns the result into pixels.
Everything above targets the UI thread. If DevTools shows the raster thread as the bottleneck, rebuild scope is not your problem — you have expensive painting, too many layers, or shader compilation, which is the chart article’s territory.
Use the DevTools rebuild counter to confirm what you assume: put the app under a realistic feed, watch which widgets rebuild, and check the count matches the number of things that actually changed. When those two numbers agree, this problem is solved.
When to skip all of it
If your data changes every few seconds, a single stream feeding one StreamBuilder at the top of the page is correct and this article is overhead. Per-symbol notifiers, frame coalescing, and repaint boundaries are what you reach for when a measurement says a screen-sized rebuild no longer fits in a frame — not before.
References
API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).
- Flutter docs, Performance best practices —
constwidgets,RepaintBoundary,itemExtent, and whyOpacityandsaveLayerare expensive. - Flutter docs, Flutter architectural overview — the UI and raster threads, and what runs on each.
- Flutter API,
ValueListenableBuilderandSchedulerBinding.scheduleFrameCallback— the two primitives the leaf-rebuild and frame-coalescing patterns are built on. - Flutter docs, Use the Performance view — UI and raster timelines, frame budget, and the rebuild counter.
Next in the Flutter notes: Channels, Pigeon, and FFI — how to cross to native without paying per tick.