Flutter

Riverpod in 2026: The Case Against Making It the Default

This post is for Flutter leads and senior developers who are about to write “state management: Riverpod” into a project template, a starter kit or a team guideline. It argues that Riverpod in 2026 is still an excellent library and a poor automatic default, and it backs that up with measurements rather than taste. You will see what one feature costs in packages, generated code and build time across three implementations, what Riverpod 3 does on its own when a request fails, and how codegen ties your Riverpod version to your Flutter SDK. This site argued the opposite in the 2025 case for Riverpod over Provider, so treat this as the counterweight. Every claim against Riverpod below has the losing side argued too.

The numbers in this post came from this environment:

Measured 2026-10-28
Windows 11 Pro 25H2 (build 26200), AMD Ryzen 5 8600G (6C/12T), 16 GB RAM
Flutter 3.35.2, Dart 3.9.0
Manual variant:  flutter_riverpod 3.3.2
Codegen variant: flutter_riverpod 3.1.0, riverpod_annotation 4.0.0,
                 riverpod_generator 4.0.0+1, build_runner 2.7.1

The codegen variant runs an older flutter_riverpod than the manual one. That is not a mistake in the setup. In fact, it is one of the findings, and the section on version lockstep shows the resolver output that forced it.

Is Riverpod Still Worth Using in 2026?

Yes, for the right app. Riverpod in 2026 is the strongest option in Flutter for apps with many async data sources that need caching, invalidation and derived state. However, it is a dependency with its own release cadence, its own runtime behaviours and an optional code generator. Picking it before you know the app needs those things means paying the costs up front for benefits that may never arrive.

That distinction is the whole argument. “Default” here means the choice made before the problem is understood: the starter template, the first commit, the line in the onboarding doc. A deliberate choice made after a team has listed its async data, its caching needs and its testing strategy is a different decision, and Riverpod often wins it.

The Feature Used to Measure Riverpod in 2026

To compare costs fairly, one feature was built three times. It is a product catalog with an async load from a repository, a search filter, a favourites toggle, a favourites count in the app bar and an error state with a retry button. The model class and the repository are byte-identical across all three versions, so every difference comes from the state layer and the screen.

Here are the three versions:

  1. Built-in: a ChangeNotifier view model and ListenableBuilder, with the repository passed in through the constructor. No packages beyond Flutter.
  2. Riverpod, manual: FutureProvider, two Notifier classes and two derived Providers, written by hand.
  3. Riverpod, codegen: the same providers declared with @riverpod annotations and generated by build_runner.

Each version ships four unit tests covering the load, the filter, the toggle and the error path, and all twelve tests passed before anything was measured.

The Built-In Version

This is the view model. It is the pattern Flutter’s own architecture recommendations describe, where view models extend ChangeNotifier and widgets observe them through ListenableBuilder.

import 'package:flutter/foundation.dart';

import 'catalog_repository.dart';
import 'product.dart';

class CatalogViewModel extends ChangeNotifier {
  CatalogViewModel(this._repository);

  final CatalogRepository _repository;

  List<Product> _products = const [];
  Object? _error;
  bool _isLoading = false;
  String _query = '';
  final Set<String> _favorites = {};

  bool get isLoading => _isLoading;
  Object? get error => _error;
  int get favoriteCount => _favorites.length;
  bool isFavorite(String id) => _favorites.contains(id);

  List<Product> get visibleProducts {
    final q = _query.trim().toLowerCase();
    if (q.isEmpty) return _products;
    return _products.where((p) => p.name.toLowerCase().contains(q)).toList();
  }

  Future<void> load() async {
    _isLoading = true;
    _error = null;
    notifyListeners();
    try {
      _products = await _repository.fetchProducts();
    } catch (e) {
      _error = e; // the UI shows a Retry button; nothing retries on its own
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  void setQuery(String query) {
    if (query == _query) return;
    _query = query;
    notifyListeners();
  }

  void toggleFavorite(String id) {
    if (!_favorites.remove(id)) _favorites.add(id);
    notifyListeners();
  }
}

Everything this class does is visible in the file. In particular, a failed load sets an error and stops, which matters later when Riverpod’s behaviour on the same failure is measured. Testing it needs no container and no overrides: construct it with a stub repository, call load(), and assert on the getters.

The Riverpod Version, Written by Hand

The manual Riverpod version splits the same state into small providers. Notably, the filter and the count become derived providers that recompute only when their inputs change.

import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'catalog_repository.dart';
import 'product.dart';

final catalogRepositoryProvider = Provider<CatalogRepository>(
  (ref) => FakeCatalogRepository(),
);

final productsProvider = FutureProvider<List<Product>>(
  (ref) => ref.watch(catalogRepositoryProvider).fetchProducts(),
);

class FavoritesNotifier extends Notifier<Set<String>> {
  @override
  Set<String> build() => const {};

  void toggle(String id) {
    state = state.contains(id) ? ({...state}..remove(id)) : {...state, id};
  }
}

final favoritesProvider =
    NotifierProvider<FavoritesNotifier, Set<String>>(FavoritesNotifier.new);

final visibleProductsProvider = Provider<AsyncValue<List<Product>>>((ref) {
  final products = ref.watch(productsProvider);
  final q = ref.watch(queryProvider).trim().toLowerCase();
  return products.whenData(
    (list) => q.isEmpty
        ? list
        : list.where((p) => p.name.toLowerCase().contains(q)).toList(),
  );
});

The queryProvider and favoriteCountProvider follow the same shape and are omitted here. This is genuinely tidy code. Loading, error and data states come from AsyncValue instead of three hand-managed fields, and whenData keeps the filter from touching a list that has not arrived yet.

The Riverpod Version With Codegen

The codegen variant declares the same graph with annotations. Consequently the declarations get shorter, while a part file appears that you never edit.

import 'package:riverpod_annotation/riverpod_annotation.dart';

import 'catalog_repository.dart';
import 'product.dart';

part 'catalog_providers.g.dart';

@Riverpod(keepAlive: true)
CatalogRepository catalogRepository(Ref ref) => FakeCatalogRepository();

@riverpod
Future<List<Product>> products(Ref ref) =>
    ref.watch(catalogRepositoryProvider).fetchProducts();

@riverpod
class Favorites extends _$Favorites {
  @override
  Set<String> build() => const {};

  void toggle(String id) {
    state = state.contains(id) ? ({...state}..remove(id)) : {...state, id};
  }
}

The screen and the tests are identical to the manual version, since generated providers keep the same names.

What One Feature Costs: Packages, Lines and Generated Code

This script counts what each version adds. It counts packages in pubspec.lock, non-blank hand-written lines under lib/, non-blank generated lines and class declarations, then times build_runner on the codegen variant five times cold and five times after a one-line edit.

#!/usr/bin/env bash
# Cost of one catalog feature, three ways. Run from the bench folder.
set -e
ms() { echo $(( ($(date +%s%N) - $1) / 1000000 )); }
median() { tr ' ' '\n' | sort -n | sed -n 3p; }

printf '%-11s %8s %10s %10s %9s\n' variant packages hand_lines gen_lines classes
for v in builtin rp_manual rp_codegen; do
  hand=$(find $v/lib -name '*.dart' ! -name '*.g.dart' | sort)
  gen=$(find $v/lib -name '*.g.dart')
  printf '%-11s %8s %10s %10s %9s\n' $v \
    "$(grep -cE '^  [a-z_0-9]+:$' $v/pubspec.lock)" \
    "$(cat $hand | grep -cv '^[[:space:]]*$')" \
    "$( [ -n "$gen" ] && cat $gen | grep -cv '^[[:space:]]*$' || echo 0)" \
    "$(cat $hand | grep -cE '^(abstract |sealed )?class ')"
done

cd rp_codegen
f=lib/catalog/catalog_providers.dart
cold=(); warm=()
for i in 1 2 3 4 5; do
  rm -rf .dart_tool/build lib/catalog/*.g.dart
  t=$(date +%s%N); dart run build_runner build --delete-conflicting-outputs >/dev/null 2>&1; cold+=($(ms $t))
done
for i in 1 2 3 4 5; do
  echo "// touch $i" >> $f        # one-line edit, like saving a provider file
  t=$(date +%s%N); dart run build_runner build --delete-conflicting-outputs >/dev/null 2>&1; warm+=($(ms $t))
done
sed -i '/^\/\/ touch /d' $f; dart run build_runner build --delete-conflicting-outputs >/dev/null 2>&1
echo "build_runner cold (ms):        ${cold[*]}   median $(echo ${cold[*]} | median)"
echo "build_runner after edit (ms):  ${warm[*]}   median $(echo ${warm[*]} | median)"

Here is the raw output of bash measure.sh:

variant     packages hand_lines  gen_lines   classes
builtin           24        125          0         5
rp_manual         66        110          0         6
rp_codegen        88        109        242         6
build_runner cold (ms):        7382 7399 7399 7417 7368   median 7399
build_runner after edit (ms):  4345 4363 4356 4498 4381   median 4363

Riverpod Wrote Less Code, and That Counts

Start with the result that cuts against this post. The hand-written Riverpod version is 110 non-blank lines against 125 for the built-in one. AsyncValue replaces three fields and a try/finally block, and the derived providers replace getters. On this feature, Riverpod is the more concise option, and a larger feature with more async sources would widen that gap.

Codegen, by contrast, saved one line over manual Riverpod. Meanwhile it added 242 lines of generated code that sit in your repository or your .gitignore, show up in code search and in diffs, and need regenerating whenever a provider signature changes. For this feature, the generator bought syntax preference and nothing measurable.

Where the Package Count Comes From

The package column needs a caveat before anyone quotes it. Of the jump from 24 to 66, most comes from one line in riverpod 3.3.2, which lists the test package as a regular dependency, and test pulls in analyzer, coverage and their own trees. The latest release, 3.4.3, depends on the lighter test_api instead, so a project on a current SDK will land well below 66. That number is real for the versions measured and should not be generalized beyond them.

The codegen delta is the one that generalizes. The extra 22 packages are build_runner, source_gen, riverpod_generator, riverpod_analyzer_utils and their dependencies, and they arrive with any build_runner-based generator. Most of them are dev dependencies, so they never reach the shipped app. They do, however, reach your dependency resolution, which is the next problem.

The Codegen Tax Is Paid on Every Save

A median of 4.4 seconds after a one-line edit sounds small. In practice, it is paid every time a provider file changes, by every developer, for the life of the project. A cold build at 7.4 seconds is paid on every fresh clone, every CI run without a warm cache and every branch switch that invalidates the build graph.

There was a moment when this looked temporary. Dart macros were expected to replace build_runner for exactly this kind of generator. In January 2025, however, the Dart team stopped work on macros and said it would invest in build times and the code generation experience instead. So codegen-based Riverpod in 2026 means build_runner for the foreseeable future, with all its friction.

What was not measured: build_runner watch, which keeps a warm process and usually rebuilds faster than a fresh build. Teams that run watch mode all day will see a smaller per-save number than 4.4 seconds. On the other hand, they also carry a background process that has to be restarted after dependency changes and occasionally after a branch switch.

Version Lockstep: Codegen Ties Riverpod to Your Flutter SDK

Here is the most underrated cost of codegen-based Riverpod in 2026, and it only appears when the newest SDK is not an option. On Flutter 3.35.2, flutter pub add flutter_riverpod resolved to 3.3.2. Adding the generator then silently pulled flutter_riverpod back to 3.1.0. Asking for both together explains why:

$ flutter pub add flutter_riverpod:^3.3.2
"flutter_riverpod" is already in "dependencies". Will try to update the constraint.
Note: meta is pinned to version 1.16.0 by flutter from the flutter SDK.
See https://dart.dev/go/sdk-version-pinning for details.
The current Dart SDK version is 3.9.0.
    Because riverpod_generator >=4.0.4-dev.1 <4.0.6 depends on analyzer ^12.0.0 which depends on meta ^1.18.0, riverpod_generator >=4.0.4-dev.1 <4.0.6 requires meta ^1.18.0.
(1) So, because every version of flutter from sdk depends on meta 1.16.0 and riverpod_generator >=4.0.6 requires SDK version >=3.12.0 <4.0.0, flutter from sdk is incompatible with riverpod_generator >=4.0.4-dev.1.
    Because riverpod_generator >=4.0.0+1 <4.0.1 depends on riverpod_annotation 4.0.0 which depends on riverpod 3.1.0, riverpod_generator >=4.0.0+1 <4.0.1 requires riverpod 3.1.0.
    And because riverpod_generator >=4.0.1 <4.0.3 depends on riverpod_annotation 4.0.1 which depends on riverpod 3.2.0, riverpod_generator >=4.0.0+1 <4.0.3 requires riverpod 3.1.0 or 3.2.0.
    And because riverpod_generator >=4.0.3 <4.0.4-dev.2 depends on riverpod_annotation 4.0.2 which depends on riverpod 3.2.1, riverpod_generator >=4.0.0+1 <4.0.4-dev.2 requires riverpod 3.1.0 or 3.2.0 or 3.2.1.
    And because flutter from sdk is incompatible with riverpod_generator >=4.0.4-dev.1 (1), if flutter from sdk and riverpod_generator >=4.0.0+1 then riverpod 3.1.0 or 3.2.0 or 3.2.1.
    And because flutter_riverpod >=3.3.2 <3.4.1 depends on riverpod 3.3.2 and rp_codegen depends on flutter from sdk, riverpod_generator >=4.0.0+1 is incompatible with flutter_riverpod >=3.3.2 <3.4.1.
    So, because rp_codegen depends on both flutter_riverpod 3.3.2 and riverpod_generator ^4.0.0+1, version solving failed.
Failed to update packages.

Read it from the bottom up and the chain is three links long. First, each riverpod_annotation release pins one exact riverpod version. Second, each riverpod_generator release depends on a specific analyzer range. Third, analyzer needs a meta version, and the Flutter SDK pins meta. As a result, the Flutter SDK you are on decides which analyzer you can have, which decides which generator you can have, which decides your Riverpod runtime version.

Why This Bites Real Teams

Plenty of production apps sit one or two Flutter releases behind, usually because a plugin, a CI image or a native toolchain has not caught up. Our Flutter, Xcode and Gradle version matrix exists because that lag is normal. On such a project, a Riverpod bug fix released in a newer minor is out of reach until the SDK moves, and without codegen it would not have been. For instance, flutter_riverpod 3.2.1 fixed “a bug where resuming a paused provider could cause it to never notify its listener ever again”, and a codegen project on this SDK resolved to 3.1.0 by default.

To be fair, the manual variant has a ceiling too. The newest flutter_riverpod, 3.4.3, requires Dart 3.12, which ships with Flutter releases newer than the 3.35.2 measured here. The difference is the width of the gap. Manual Riverpod got the newest release its SDK allowed, whereas codegen pulled it back two minors further.

Defaults That Surprise: Riverpod 3 Retries Failures on Its Own

Riverpod 3.0, released in September 2025, added automatic retry: providers that fail now refresh with exponential backoff. That is a sensible feature. As a default, though, it changes what a failing request does without any line in your code saying so.

This test counts how often a failing FutureProvider runs when nothing but one listener is attached:

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  test('one failing FutureProvider, one listener, 40 seconds', () async {
    final started = DateTime.now();
    final callTimes = <int>[];
    final failing = FutureProvider<int>((ref) async {
      callTimes.add(DateTime.now().difference(started).inMilliseconds);
      throw Exception('backend returned 503');
    });
    final container = ProviderContainer();
    addTearDown(container.dispose);
    container.listen(failing, (_, __) {});
    await Future<void>.delayed(const Duration(seconds: 40));
    print('calls: ${callTimes.length}');
    print('call times (ms): $callTimes');
  }, timeout: const Timeout(Duration(seconds: 60)));
}

The raw output of flutter test test/retry_total_test.dart:

00:00 +0: one failing FutureProvider, one listener, 40 seconds
calls: 11
call times (ms): [12, 227, 633, 1442, 3044, 6246, 12647, 19049, 25451, 31865, 38274]
00:40 +1: All tests passed!

One failure produced eleven calls over 38 seconds: the original plus ten retries, doubling from 200 ms and capping at 6.4 seconds. Those values match ProviderContainer.defaultRetry in the 3.3.2 source, which allows ten retries with a 200 ms minimum and a 6400 ms maximum delay.

Exception Retries, Error Does Not

A second test threw two different types from otherwise identical providers and counted calls at three points in time:

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';

// Counts how often Riverpod 3 re-runs a failing provider on its own.
Future<int> callsAfter(Object Function() makeError, Duration wait) async {
  var calls = 0;
  final failing = FutureProvider<int>((ref) async {
    calls++;
    throw makeError();
  });
  final container = ProviderContainer();
  addTearDown(container.dispose);
  container.listen(failing, (_, __) {});
  await Future<void>.delayed(wait);
  return calls;
}

void main() {
  test('default retry: Exception vs Error, calls after 1s / 3s / 7s', () async {
    for (final seconds in [1, 3, 7]) {
      final wait = Duration(seconds: seconds);
      final asException = await callsAfter(() => Exception('offline'), wait);
      final asError = await callsAfter(() => StateError('offline'), wait);
      print('after ${seconds}s  Exception: $asException calls   StateError: $asError calls');
    }
  });
}

The printed lines from flutter test test/retry_test.dart:

after 1s  Exception: 3 calls   StateError: 1 calls
after 3s  Exception: 4 calls   StateError: 1 calls
after 7s  Exception: 6 calls   StateError: 1 calls

The default retry skips anything that extends Error, so a StateError is never retried while an Exception is. That is a reasonable rule, because an Error usually means a bug. Even so, it means two repositories that fail in slightly different ways produce very different backend traffic, and the difference lives in a framework default rather than in the repository.

What That Means in Production

Picture a backend that starts returning 503s. Every screen with a failing FutureProvider now fires up to eleven requests instead of one, at the moment the backend is least able to absorb them. That is the shape of a retry storm, and none of it appears in application code. By contrast, the built-in view model above fails once and waits for the user to press Retry.

Fortunately, the fix is one line, either globally or per provider:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'catalog/catalog_screen.dart';

void main() {
  runApp(
    ProviderScope(
      // Returning null means "do not retry". Keep retries in the HTTP layer,
      // where they can respect Retry-After and idempotency.
      retry: (retryCount, error) => null,
      child: const MaterialApp(home: CatalogScreen()),
    ),
  );
}

None of this makes the default wrong. Rather, a team adopting Riverpod in 2026 as a default inherits behaviours like this one, plus listeners paused when their widget is not visible and notifications filtered by == across every provider type, and each has to be learned before it causes a confusing bug.

The API Has Moved With Every Major Version

Anyone evaluating Riverpod in 2026 should look at the last four years of its changelog, because Riverpod has shipped three major versions since late 2021, and each one reshaped how providers are written. Version 2 introduced Notifier and AsyncNotifier and pushed StateNotifier aside. Version 3, in September 2025, moved StateProvider, StateNotifierProvider and ChangeNotifierProvider behind a separate legacy.dart import, removed the AutoDispose interface variants and replaced the per-provider Ref subclasses with a single Ref.

There is a small irony in the details too. The 2.0 changelog says providers “no-longer throw a ProviderException” and rethrow the original error instead, while 3.0 notes that exceptions in providers “are rethrown as a ProviderException” again. Code that caught specific exception types had to change in both directions.

The churn has not stopped. The flutter_riverpod 3.2.0 changelog deprecates family.overrideWith in favour of overrideWith2 and states that “In 4.0.0, overrideWith2 will be renamed to overrideWith“, which is a rename scheduled to break test code that has already migrated once. Meanwhile, riverpod_generator 4.0.0 made generated providers non-constant, and the two flagship 3.0 features, offline persistence and mutations, are labelled experimental with a warning that the API “may change in breaking ways without a major version bump”.

None of that is unusual for an actively developed library. However, it is a cost that the built-in ChangeNotifier, whose core addListener and notifyListeners API predates Flutter 1.0, does not have.

What Flutter Already Ships for State

Flutter’s own guidance is the strongest argument in this post, and it is not from a competitor. The Flutter architecture recommendations say to “Use ChangeNotifiers and Listenables to handle widget updates” and recommend the provider package for dependency injection. The official Compass case study builds a full app with ChangeNotifier view models and ListenableBuilder, and mentions third-party state managers only as an option.

Here is how the two compare on the axes that matter for a default:

ConcernFlutter built-insRiverpod 3
Extra runtime packagesNoneflutter_riverpod, riverpod and their dependencies
Async loading and error stateHand-written fieldsAsyncValue, built in
Caching and invalidationHand-writtenref.invalidate, auto-dispose, keepAlive
Derived stateGetters, recomputed on readDerived providers, memoized
Behaviour on failureWhatever your code doesRetries up to 10 times by default
Code generationNoneOptional, via build_runner
API stabilitySame core API since Flutter 1.0Three majors since 2021, a fourth announced
Onboarding costAny Flutter developerwatch, read, listen, scoping, families

If your app’s state fits in the left column without contortion, the right column is cost without benefit. If it does not, the right column is where the value is, and the next section makes that case.

The Case for Riverpod, Argued Honestly

The losing side deserves its strongest form, and the case for Riverpod in 2026 is strong.

First, async data is where Riverpod earns its keep. An app with a dozen API-backed resources, each needing loading and error states, pull-to-refresh, invalidation after a mutation and disposal when nobody is looking, will reimplement half of Riverpod by hand with ChangeNotifier. The 15 lines saved on one catalog feature become hundreds across an app like that, and the hand-written versions will each have their own bugs. Our guide to Riverpod async notifiers for API state shows that pattern done properly.

Second, derived state is cheaper. A derived provider computes once per change no matter how many widgets read it, whereas a getter on a view model recomputes on every read. Our measured Riverpod vs Bloc rebuild counts found exactly this: three readers cost 16 computations with a derived provider against 48 with per-site selectors.

Third, testing with overrides is excellent. Swapping a repository for a stub is one overrideWithValue call, and it works across the whole graph rather than only where you remembered to pass a constructor argument.

Finally, a team that already knows Riverpod well loses most of the onboarding cost, and for them the retry and pausing behaviours are known quantities. If that describes your team, much of this post’s argument weakens, and it should.

A Real Scenario: Choosing a Default for a New App

Consider a small team of three or four Flutter developers starting a mid-sized app expected to reach 20 to 30 screens over its first year. Most screens are forms and lists backed by a handful of REST endpoints, and the team includes one developer new to Flutter. The template from a previous project says Riverpod with codegen, and the question is whether to keep it.

Working through the costs above, the honest answer is usually to start with ChangeNotifier view models and constructor injection, and to keep a short list of signals that would justify adding Riverpod. Those signals are concrete: more than a few resources that need shared caching, repeated hand-written invalidation logic, or derived values read from many places. The trade-off is real, however. If those signals show up around month four, migrating a dozen view models costs more than having started with Riverpod, so a team that already expects heavy async state should skip the experiment and choose Riverpod deliberately, without codegen unless they want it for its own sake.

The clean architecture case against default layering makes the same structural point about layers: add the abstraction when the problem appears, not when the project starts.

When to Use Riverpod in 2026

  • The app has many async resources that need caching, refresh and invalidation after writes
  • Derived values are read from several places and are expensive enough that memoization matters
  • The team already knows Riverpod’s watch, read, listen and scoping rules well
  • Tests need to swap dependencies deep in the graph without threading constructor arguments
  • The project tracks the latest Flutter stable closely, so version lockstep rarely bites

When NOT to Use Riverpod as the Default

  • The state is mostly forms, toggles and a few screen-scoped lists that ChangeNotifier handles cleanly
  • The project lags behind the newest Flutter SDK, which is where codegen lockstep caps your runtime version
  • Several team members are new to Flutter and still learning BuildContext, keys and rebuilds
  • The app has to stay stable for years with little maintenance budget, since three majors in four years is the observed pace
  • Nobody on the team can say which concrete problem Riverpod would solve for this app

Common Mistakes with Riverpod

  • Adopting codegen by default, which on the feature measured here saved one line and cost 242 generated lines plus 4.4 seconds per edit
  • Leaving the default retry on for providers that call your backend, which turned one failure into eleven requests in this measurement
  • Throwing StateError from a repository and expecting retries, since Error subclasses are never retried by default
  • Counting repository calls in tests without disabling retry, then chasing a flaky assertion that is really a backoff timer
  • Upgrading Flutter and Riverpod separately on a codegen project, then losing an afternoon to an analyzer and meta resolution failure
  • Treating Riverpod as the cause of slow screens, when a state management mistake like a screen-wide subscription produces the same cost in any library

What This Measurement Does Not Cover

A benchmark with unstated limits is a trap, so here are the limits. The feature is small, with one async source, and Riverpod’s line-count advantage grows with every additional async resource. Build times are for build_runner build from a fresh process, not build_runner watch, and they come from one machine. The package counts are specific to riverpod 3.3.2, whose dependency on test was replaced by test_api in later releases.

Furthermore, nothing here measures runtime performance, app size or frame times, and the retry counts come from unit tests rather than a device. Finally, the environment is Flutter 3.35.2, chosen because it is the SDK pinned on this machine. On the newest Flutter stable, the lockstep section’s specific versions change, though the mechanism, an exact riverpod pin per annotation release and an SDK-pinned meta, does not.

Conclusion

Riverpod in 2026 is a strong library for apps dominated by async, cached and derived state, and on the feature measured here it even took fewer hand-written lines than Flutter’s built-ins. As a default, though, it brings costs that arrive whether or not the app needs it: codegen that added 242 generated lines and 4.4 seconds per edit, a retry policy that turned one failure into eleven requests, and a version chain that tied the Riverpod runtime to the Flutter SDK.

So make it a decision rather than a default. Start your next app with ChangeNotifier view models, write down the two or three signals that would justify Riverpod, and add it when one appears. If you already run Riverpod, add retry: (retryCount, error) => null to your ProviderScope today unless you have chosen otherwise on purpose. For the other side of this argument, reread the case for Riverpod over Provider and decide which one describes your app.