BLOG · #Engineering

Flutter under a firehose: making rebuild scope match change scope

A quote list that updates hundreds of times a second will destroy a Flutter app built the ordinary way. The fix isn’t a faster state management library — it’s making the rebuilt subtree as small as the thing that actually changed, and conflating updates onto the frame.

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.builder with itemExtent. A known extent lets Flutter skip measuring children to compute scroll geometry — a large win for uniform rows.
  • const constructors everywhere they’re possible. A const widget is skipped during rebuild instead of being reconstructed and compared.
  • RepaintBoundary around 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) inside build() 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).


Next in the Flutter notes: Channels, Pigeon, and FFI — how to cross to native without paying per tick.