Flutter

Riverpod vs Bloc Rebuild Counts: Measured on One Screen

Ask which Flutter state manager rebuilds less and you will get an answer within seconds, usually that Riverpod’s fine-grained providers beat Bloc’s broader rebuild patterns. That claim is everywhere, including in our own earlier comparison of Riverpod and Bloc, and almost nobody has counted. This post counts. It reports the Riverpod vs Bloc rebuild counts for one screen, wired six different ways, driven through an identical sixteen-step user session, with the widget test and its raw output included so you can rerun or refute it. The headline result is not the one the folklore predicts, and the two numbers that do differ are not the ones anybody argues about.

How These Riverpod vs Bloc Rebuild Counts Were Produced

A rebuild count is only trustworthy if the two implementations render literally the same widgets. Therefore the five leaf widgets under test live in one file, and both the Riverpod tree and the Bloc tree import that same file. Each leaf calls a static counter from its build method, so a rebuild is a plain integer instead of a screenshot of a profiler.

Here is the exact environment every number below came from:

Measured 2026-09-02
Windows 11 Pro 25H2 (build 26200), AMD Ryzen 5 8600G (6C/12T), 16 GB RAM
Flutter 3.35.2, Dart 3.9.0
flutter_riverpod 3.3.2, riverpod 3.3.2
flutter_bloc 9.1.1, bloc 9.2.1

Counts are integers produced by flutter test, not timings, so there is nothing to average. Even so, the suite was run five times and every count in this post was identical on all five runs. Consequently the numbers reported are single values rather than medians.

One measurement detail matters more than it looks. Bloc delivers state through a stream, so a rebuild triggered by emit lands a microtask after the call, whereas Riverpod marks its dependents dirty synchronously. An early version of this benchmark called tester.pump() once per change and reported Bloc at roughly half of Riverpod’s rebuilds, which was an artifact of the harness rather than a property of Bloc. Every run below therefore uses tester.pumpAndSettle() after each change, so both frameworks are counted only once the tree has stopped changing. If you build your own version of this test, that is the trap.

The Screen Both Implementations Render

The subject is a shopping cart screen with five leaves, chosen because each one depends on a different slice of state. TitleBar depends on nothing. CountBadge depends on the number of lines. TotalBar depends on the total. SearchStatus depends on the query, and SpinnerSlot depends on a sync flag.

import 'package:flutter/material.dart';

import 'cart_state.dart';

class CountBadge extends StatelessWidget {
  const CountBadge({super.key, required this.count});

  final int count;

  @override
  Widget build(BuildContext context) {
    Probe.hit('CountBadge'); // the whole measurement is this one line
    return Text('$count items');
  }
}

class TotalBar extends StatelessWidget {
  const TotalBar({super.key, required this.totalCents});

  final int totalCents;

  @override
  Widget build(BuildContext context) {
    Probe.hit('TotalBar');
    return Text('${totalCents / 100}');
  }
}

The State Class and Its Equality Contract

The state class is immutable and implements == by value, because both frameworks suppress a notification when the new state equals the old one. Skipping that override changes the result, which is measured separately further down.

class CartState {
  const CartState({
    required this.lines,
    required this.query,
    required this.isSyncing,
  });

  final List<CartLine> lines;
  final String query;
  final bool isSyncing;

  // Derived, so it changes only when a quantity or a price changes.
  int get totalCents => lines.fold(0, (sum, line) => sum + line.lineCents);

  @override
  bool operator ==(Object other) {
    if (other is! CartState) return false;
    if (other.query != query || other.isSyncing != isSyncing) return false;
    if (other.lines.length != lines.length) return false;
    for (var i = 0; i < lines.length; i++) {
      if (other.lines[i] != lines[i]) return false;
    }
    return true;
  }

  @override
  int get hashCode => Object.hash(Object.hashAll(lines), query, isSyncing);
}

The Sixteen-Step Session

Every implementation is then driven through the same session: five quantity bumps on one line, six toggles of the sync flag, and five keystrokes typed into the search box. That is sixteen state changes in total. Notably, the line count never changes during the session, so a perfectly wired screen would rebuild CountBadge zero times and TitleBar zero times.

// The identical user session every implementation is put through.
Future<void> driveBloc(WidgetTester tester, CartCubit cubit) async {
  for (var i = 0; i < 5; i++) {
    cubit.bumpQuantity(0);
    await tester.pumpAndSettle();
  }
  for (var i = 0; i < 6; i++) {
    cubit.setSyncing(i.isEven);
    await tester.pumpAndSettle();
  }
  for (final q in ['k', 'ke', 'key', 'keyb', 'keybo']) {
    cubit.setQuery(q);
    await tester.pumpAndSettle();
  }
}

The minimum possible total is therefore sixteen rebuilds: five for TotalBar, five for SearchStatus, six for SpinnerSlot, and none for the two leaves whose inputs never change.

Three Ways to Wire It, in Each Framework

Both frameworks get the same three treatments, from the most common mistake to the tuned version.

The screen-level variant reads state once at the top and pushes values down. This is what most screens look like before anybody profiles them.

class RiverpodScreenLevel extends ConsumerWidget {
  const RiverpodScreenLevel({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final cart = ref.watch(cartProvider); // one watch, whole subtree rebuilds
    return Frame(
      title: const TitleBar(),
      count: CountBadge(count: cart.lines.length),
      total: TotalBar(totalCents: cart.totalCents),
      search: SearchStatus(query: cart.query),
      spinner: SpinnerSlot(isSyncing: cart.isSyncing),
    );
  }
}

Its Bloc twin does the same thing with a single BlocBuilder wrapping the frame:

class BlocScreenLevel extends StatelessWidget {
  const BlocScreenLevel({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<CartCubit, CartState>(
      builder: (context, cart) => Frame(
        title: const TitleBar(),
        count: CountBadge(count: cart.lines.length),
        total: TotalBar(totalCents: cart.totalCents),
        search: SearchStatus(query: cart.query),
        spinner: SpinnerSlot(isSyncing: cart.isSyncing),
      ),
    );
  }
}

The leaf-level variant moves the subscription down to each leaf but still reads the whole state object. Finally, the tuned variant narrows each subscription to the single field that leaf renders, using select on the Riverpod side and BlocSelector on the Bloc side.

// Riverpod, tuned: this leaf wakes only when the boolean flips.
class _SelectSpinner extends ConsumerWidget {
  const _SelectSpinner();

  @override
  Widget build(BuildContext context, WidgetRef ref) =>
      SpinnerSlot(isSyncing: ref.watch(cartProvider.select((s) => s.isSyncing)));
}
// Bloc, tuned: BlocSelector compares the selected value, not the whole state.
BlocSelector<CartCubit, CartState, bool>(
  selector: (s) => s.isSyncing,
  builder: (context, syncing) => SpinnerSlot(isSyncing: syncing),
)

The Result: Riverpod and Bloc Tie Exactly

Here is the raw output of flutter test test/rebuild_counts_test.dart, unedited apart from shortening the absolute path on the first line:

00:00 +0: loading test/rebuild_counts_test.dart
00:00 +6: TABLE 1: rebuilds over 16 state changes
implementation               TitleBar CountBadge   TotalBarSearchStatusSpinnerSlot   total
------------------------------------------------------------------------------------------
Riverpod screen-level               0         16         16         16         16      64
Riverpod leaf, whole state          0         16         16         16         16      64
Riverpod leaf, select               0          0          5          5          6      16
Bloc screen-level                   0         16         16         16         16      64
Bloc leaf, whole state              0         16         16         16         16      64
Bloc leaf, BlocSelector             0          0          5          5          6      16
------------------------------------------------------------------------------------------
minimum possible                    0          0          5          5          6      16

Read that table twice, because it contradicts the thing everybody repeats. The Riverpod vs Bloc rebuild counts are identical in all three configurations. Naive costs 64 rebuilds in both frameworks, which is four times the necessary work. Tuned costs 16 in both, which is exactly the floor. Whichever library you picked, you landed on the same number.

The CountBadge column is the clearest illustration. Its input, the line count, never changes across the whole session, and yet both naive versions rebuild it sixteen times. Both tuned versions rebuild it zero times. The four-times-over-work is not a property of Bloc or of Riverpod. It is a property of subscribing to a whole state object.

Why the Two Frameworks Tie

The tie is not a coincidence, and the reason is visible in both packages’ source. Bloc drops an emit whose state equals the current state, in bloc_base.dart:

if (state == _state && _emitted) return;

Riverpod does the same job through updateShouldNotify, which defaults to a value comparison in element.dart. Once both libraries dedupe on ==, the remaining question is only how wide each subscription is, and that is decided by where you put select or BlocSelector rather than by which package is in your pubspec.yaml.

This also explains why TitleBar scores zero in all six rows. It takes no parameters, so it is constructed as const, and Flutter skips rebuilding a widget whose instance is identical to the previous one. That zero belongs to the const keyword, not to either state manager.

Where the Numbers Actually Diverge

Two measurements did separate the frameworks, and neither is about rebuild counts on a normal screen.

The first is the very first emit. Bloc’s guard requires _emitted to be true, so a cubit’s first emit is never deduplicated even when the state is equal to the initial state:

one emit, equal to the initial state, before any other emit
Riverpod, four watching leaves  rebuilds: 0
Bloc,     four watching leaves  rebuilds: 4

In practice this bites when a screen dispatches a refresh on mount and the refresh returns the same data it started with. Riverpod swallows it. Bloc rebuilds every listener once. Four wasted rebuilds is not a performance problem, but it is a real difference in behaviour, and it will show up if you assert on rebuild counts in a widget test.

Derived Values: One Computation Against Three

The second divergence is about computation rather than rebuilds. Suppose three separate widgets all want lines.length. In Riverpod you express that as a derived Provider, which recomputes once per change no matter how many widgets read it. In Bloc you write a BlocSelector at each of the three sites, and every selector runs on every emit:

three widgets reading lines.length, over the same 16 emits
Riverpod derived Provider  computations: 16  rebuilds: 0
Bloc BlocSelector x3       computations: 48  rebuilds: 0

Both produce zero rebuilds, which is the correct answer. However, Riverpod ran the derivation 16 times and Bloc ran it 48 times, exactly three times as often. For lines.length that difference is free. For a derivation that sorts, filters or diffs a list of a few thousand items, and that is read from several places on one screen, the multiplier is the whole story. Bloc’s answer is to compute the value once inside the state class and store it as a field, which works, though it moves the memoization into code you maintain.

The Mistake That Costs More Than the Choice

Both frameworks depend on value equality, so both collapse in the same way when the state class does not implement it. Running the identical five redundant emits against a state class with no == override produces this:

five emits of an equal-valued state whose class has no ==
Riverpod, no ==                 rebuilds: 5
Bloc,     no ==                 rebuilds: 5

With == in place, the same five emits produce zero rebuilds in both. Missing value equality therefore turns off deduplication entirely, in either library, and it is far easier to do by accident than picking the wrong state manager. Use Equatable, or freezed, or a hand-written operator, but do not ship a state class without one. Our roundup of common Flutter state management mistakes covers the neighbouring versions of this same failure.

What This Benchmark Does Not Measure

A benchmark with unstated limits is a trap, so here is what these numbers do not cover.

They count build calls, not frame times. A rebuild of a Text widget is close to free, while a rebuild that reruns an expensive layout is not, and this test deliberately uses trivial leaves so the count is unambiguous. They also cover a Cubit rather than a full Bloc with events, since event dispatch adds latency but not extra rebuilds. Furthermore, the screen has five leaves and a flat tree, whereas a real screen has lists, and ListView.builder changes the arithmetic by only building visible children.

Finally, this is Riverpod 3.3.2 and flutter_bloc 9.1.1 on Flutter 3.35.2. Both packages have changed their notification internals before, so treat the exact integers as pinned to those versions.

A Real Screen, Not a Benchmark

Consider a mid-sized commerce app with 20 to 30 screens, where a small team notices the cart page dropping frames while a user types in the search box. The instinct is to blame the state manager and open a migration ticket. Yet the pattern in this measurement suggests where to look first: a single subscription near the top of the screen, feeding a subtree of a dozen widgets, so that every keystroke rebuilds all of them.

The trade-off is real, though. Moving from one subscription to a dozen narrow ones costs readability, since the data flow stops being visible in one place, and it adds a select or BlocSelector that a future refactor can silently widen again. On a screen with three cheap widgets that cost is not worth paying. On a list row rendered eighty times it certainly is. That judgement, not the package choice, is what the numbers above are actually about.

When to Use Narrow Subscriptions

  • A single state change currently rebuilds a subtree larger than about ten widgets
  • The rebuilt widgets do real layout work, such as measuring text, clipping or shadows
  • The screen is driven by a high-frequency source like a text field, a scroll offset or an animation value
  • The widget is inside a list row, so every wasted rebuild is multiplied by the number of visible rows
  • A derived value is expensive to compute and is read from several places on the same screen

When NOT to Use Narrow Subscriptions

  • The subtree under one subscription is a handful of cheap widgets, where 64 rebuilds of a Text costs nothing measurable
  • No profiler run has shown the screen dropping frames, since the readability cost is certain and the gain is not
  • The state object has fewer fields than the screen has consumers, so nearly every change is relevant to nearly every widget
  • You are choosing between Riverpod and Bloc on rebuild grounds, because the table above shows there is nothing to choose between

Common Mistakes with Rebuild Optimization

  • Shipping a state class without ==, which disables deduplication in both frameworks and costs more than any wiring choice
  • Reading the whole state object inside a leaf, which looks narrow but subscribes to every field
  • Wrapping widgets in Builder or splitting classes without narrowing the subscription, which moves the rebuild rather than removing it
  • Dropping const from widgets that take no parameters, which converts a free zero into a rebuild on every parent build
  • Measuring with a single tester.pump() and concluding Bloc rebuilds less, when the missing rebuilds are simply still queued
  • Migrating between state managers to fix rebuilds, then reproducing the same screen-level subscription in the new library

Reproducing or Refuting This

Everything here comes from one widget test against a stock flutter create project with two dependencies added. To rebuild it, run flutter pub add flutter_riverpod flutter_bloc, put your five leaf widgets in one shared file, add a static counter to each build method, and drive the same sequence of changes through both trees with pumpAndSettle between each one. The whole thing takes under an hour and it answers the question for your screen rather than for this one.

For the runtime side of the same trade-offs, our guide to Flutter performance optimization covers what a rebuild actually costs once it reaches layout and paint. On the testing side, widget and integration testing practices explains the harness this benchmark is built on.

Conclusion

The Riverpod vs Bloc rebuild counts on this screen are identical: 64 rebuilds when wired naively, 16 when tuned, in both frameworks. The differences that survived measurement are narrow, namely Bloc’s undeduplicated first emit and Riverpod’s memoized derived providers, and neither is a reason to migrate. What actually decides the number is where you put the subscription and whether your state class implements value equality.

So do not open a migration ticket. Open your slowest screen, add a counter to two or three leaf widgets, and find out how much of the work is thrown away. If you are still weighing the libraries themselves rather than the wiring, our broader comparison of Provider, Riverpod and Bloc argues that on ergonomics and testability, which is where the real difference lives, and Riverpod’s async notifiers covers the API-state patterns this cart screen only hints at.

Leave a Comment