# 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.

- Canonical (HTML): https://kaguc.com/blog/flutter-highfreq-state/
- Date: 2026-08-28


*Also in [中文](/blog/flutter-highfreq-state-zh/). Flutter implementation notes for [Trading app architecture](/blog/trading-app-architecture/); the principle behind it is in [Real-time data pipelines](/blog/realtime-data-pipeline/).*

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.

```dart
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
  }
}
```

```dart
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:

```dart
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](/blog/realtime-data-pipeline/) 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:

```dart
@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](/blog/flutter-chart-rendering/) 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](https://docs.flutter.dev/perf/best-practices) — `const` widgets, `RepaintBoundary`, `itemExtent`, and why `Opacity` and `saveLayer` are expensive.
- Flutter docs, [Flutter architectural overview](https://docs.flutter.dev/resources/architectural-overview) — the UI and raster threads, and what runs on each.
- Flutter API, [`ValueListenableBuilder`](https://api.flutter.dev/flutter/widgets/ValueListenableBuilder-class.html) and [`SchedulerBinding.scheduleFrameCallback`](https://api.flutter.dev/flutter/scheduler/SchedulerBinding/scheduleFrameCallback.html) — the two primitives the leaf-rebuild and frame-coalescing patterns are built on.
- Flutter docs, [Use the Performance view](https://docs.flutter.dev/tools/devtools/performance) — UI and raster timelines, frame budget, and the rebuild counter.

---

*Next in the Flutter notes: [Channels, Pigeon, and FFI](/blog/flutter-native-channels/) — how to cross to native without paying per tick.*

