Filip Hráček / text /

Benchmark: Flutter state management approaches and thier performance

I’m writing a book on UI performance, and I’m trying to make my Flutter game as performant as possible, and a big part of those endeavors turns out to be performance benchmarking. Since I now have a rig for this (underclocked temperature-controlled, no-governor, no-autoscaling battery-less Android device with ENID emulator) and a methodology, I thought it might be fun to test-drive it on the perennial discussion:

What’s the best state management approach for Flutter apps?

Obviously, with a perf rig and benchmark, I can only answer the part about performance, and even that with a huge amount of important caveats.

But, anyway, here’s question I’m trying to address in this post:

Everything else being equal, does any state management approach have a significant effect on the performance of the app?

By “state management approach”, I mean what most people in the community understand under that term — things like “ChangeNotifier + Provider” or “package:bloc”. I took the samples in Brian Egan’s storied flutter_architecture_samples project. I added Riverpod and GetX, which were missing and are among the most popular today. Thanks to the way Brian’s repository is built, it was easy to ask an LLM to build the app according to the same specs as the existing samples. I also asked the LLM to go through the existing samples and find discrepancies (e.g., some implementations added a Hero widget, others didn't). Also, I didn't measure outdated libraries that are still part of the repo but that aren’t used anymore, like scoped_model or redux.

By “performance of the app”, I simply mean the build time. There is not much else, performance-wise, on which a state management approach can have an effect.

And by “significant effect”, I mean a statistically significant ($\alpha = 0.05$) change of 1 percent or more. Note that, in any sane app, state management will only take a fraction of the build times, so a change of -1% is actually pretty significant. I could create a benchmark that only exercises the state management libraries and nothing else, but I explicitly didn't want to do that. Numbers from such micro-benchmarks are meaningless because nobody actually uses state management libraries in this way. I would have to come up with some exercise like “change this state 10 thousand times” that no actual app will ever see. In contrast, by measuring the app in its entirety, we can see how state management approaches perform in a realistic scenario, and we get a real number (“the app gets X% faster build times with this approach”) instead of an abstract one (“the approach gets Y% better benchmark score”).

The benchmark harness I’m using isn’t open source and I can’t reasonably explain every aspect of it without balooning this article into, well, a book about UI performance... But at least I can briefly list the things I controlled for or addressed, so that when you look at the results below, you don’t immediately dismiss them with something like “well, that’s because the idiot didn't account for XYZ”. Well, actually, the idiot might have not accounted for XYZ, but he did account for (or eliminated):

The benchmark ran for 5 days, non-stop, not counting the pilot (for sample size computation) session before it. There were 2100 trials for the 7 approaches under test; the number of rounds was $n = 300$ (which is therefore our sample size).

I used vanilla for the baseline. Vanilla is the lowest-level approach, using setState() and widgets. Unless I say differently, all datapoints below measure the difference from vanilla. This approach to measurement avoids several pitfalls of benchmarking (which, again, I don’t have the time to explain here).

Initial result

Before you grab a screenshot and run, please read on. This graph seems to tell a different story than it’s actually telling — but you need further context for that.

Full size. The y-axis is in microseconds, relative to vanilla.

Ignore bloc_library for now. From the rest of the graph, we can surmise, with a 95% probability (remember, $\alpha = 0.05$) that:

Now, the result of bloc_library (which is how Brian’s repo calls package:bloc because he'd been experimenting with BLoC-like approaches before that library existed) is surprising. From the graph, it looks as if just by using package:bloc, you shave about 21 microseconds from your average build time. That’s still a teeny tiny amount (0.263% on 120 Hz screens) but at least it’s something you could conceivably care about, in aggregate.

But is it really some magical efficiency that comes from package:bloc itself?

No, I don’t think it is. If you look at how the bloc_library sample differs from the others, it is more granular with its rebuilds, and also moves the loading indicator logic lower in the widget tree. Unfortunately, it is non-obvious just how different the resulting widget trees and their transformations are.

Clearly, some differences come from the state management library itself — I’ll call them differences A. Then there are those differences that the library’s users gravitate to (e.g., the library making it easier or harder to split things into independently observable objects) — differences B. And then, of course, there are tastes and conventions used by the specific developer who implemented the specific sample code — differences C.

When comparing the performance of state management approaches, differences A and differences B clearly matter. But differences C is just noise.

I thought using flutter_architecture_samples will weed out the differences C, but I was wrong. The repository has existed for 9 years now, and has been contributed to by a number of people, many of them not your average Flutter developer (Felix Angelov has contributed to the bloc_library sample; Remi Rousselet to the freezed_provider_value_notifier sample; etc.). Like its TodoMVC inspiration, flutter_architecture_samples is a showcase of architectural approaches, and not a benchmark suite.

I can’t prove that bloc_library's advantage would disappear if we eliminated differences C. Maybe package:bloc really does make some build methods faster (differences A) or maybe it does, in general, lead developers to more efficient build methods (differences B). But I have a strong feeling that if we took an average package:bloc developer, and an average package:signals developer, and asked them to implement the app spec in their respective library, the results would be the same or very similar.

In fact, if you look at the current implementation of the signals sample in the flutter_architecture_samples repo, you’ll find that it uses a deprecated Watch() widget, and the linter even flags it with a mention of a better approach for reactivity.

Making every sample perfectly comparable

So, I thought, why don’t I just fix all the samples? Why not insist on smallest possible rebuilds, regardless of architecture used?

Unfortunately, this isn’t as obvious as it seems. For example, for the ChangeNotifierProvider sample, it is very much possible to only listen to what you need, and it doesn’t add very many new lines of code, but the solution is awkward and something few developers would write, in my opinion. You get things like Provider.of<TodoListModel>(context, listen: false) in the build method, just so you can use the model in a callback. This feels unintuitive and, to me personally, icky.

  @override
  Widget build(BuildContext context) {
    final model = Provider.of<TodoListModel>(context, listen: false);

    return IgnorePointer(
      ...
      child: AnimatedOpacity(
        ...
        child: Selector<TodoListModel, VisibilityFilter>(
          selector: (_, model) => model.filter,
          builder: (context, activeFilter, _) {
            return PopupMenuButton<VisibilityFilter>(
              ...
              // This is where we use the model.
              onSelected: (filter) => model.filter = filter,
              ...
            );
          },
        ),
      ),
    );
  }

I tried fixing things with help from an LLM, but the results seemed sub-par even to me, someone who isn’t an expert in all these architectural approaches.

If someone has the time and energy to really put all the approaches in flutter_architecture_samples on the exact same footing, rebuild-wise, then please let me know and I’ll be happy to run your results through my benchmarking rig. Likewise if you have an idea for a TodoMVC-like sample app that captures the complexity of real-world apps while focusing on performance over architecture — let me know.

As for me, I am reasonably satisfied. Until I see more data, I don’t think any popular state management technique can claim performance victory over any other one. It may be that some microbenchmark shows one approach as superior to another, but in practical terms, when applied in apps, they are all so close to each other that it doesn’t matter. And the 5-6 microseconds of overhead over using setState() everywhere and for everything are well worth it, in my opinion. I’ll continue using ChangeNotifier and package:provider wherever I can get away with it, but not because of performance — I just like the simplicity of it when one is dealing with simple cases.

— Filip Hráček
September 2026