Fourteen providers, three release cycles, zero rollback. That’s the number that matters, because the more common outcome for a “let’s migrate to Riverpod” ticket is a two-week branch that dies in code review because nobody can verify the new state layer behaves the same as the old one under real usage. We ran this migration on a live app with an existing App Store rating to protect, not a greenfield project, which meant the constraint wasn’t “get to Riverpod” — it was “get to Riverpod without a release where users notice.”
The app was on a mix of Provider and ad hoc ChangeNotifier singletons stitched together with a service locator. It worked, mostly, but two bugs a quarter traced back to notifier lifecycle: a listener surviving a logout and firing against stale user data, and a widget rebuilding on a provider it didn’t actually depend on because the notifier bundled four unrelated pieces of state into one class. Riverpod’s compile-time safety and scoped invalidation fix both problems structurally. But “fix them structurally” is not a justification for a big-bang rewrite of a state layer that touches every screen.
The migration order was decided by blast radius, not by ease
The instinct is to migrate the easy providers first to build momentum. We inverted that. We ranked every provider by two axes: how many widgets read from it, and how much of the app’s critical path it touched (auth, payment, sync). The lowest blast-radius provider — a settings toggle for notification preferences, read by exactly one screen — went first, purely as a rehearsal for the pattern we’d use everywhere else: run the old ChangeNotifier and the new Notifier side by side behind a flag, mirror writes to both, and diff reads in debug builds.
The auth provider went last, deliberately, after eleven other migrations had validated the pattern. That’s the opposite of “save the hard part for when you understand the codebase better” — by the time we touched auth, we’d already hit and fixed the edge cases the pattern didn’t anticipate on lower-stakes providers.
- Leaf providers first (settings, feature flags, UI-only state) — no downstream providers depend on them, so a mistake stays contained to one screen.
- Fan-out providers next (user profile, cart contents) — read in many places but written from few, so we could migrate the write path and leave old read call-sites on a compatibility shim for a full release cycle before touching them.
- Root providers last (auth session, connectivity) — everything downstream depends on their shape, so any latent bug here surfaces everywhere, not just in one feature.
The fan-out category is where most of the actual engineering time went. A provider read in nine places can’t be flipped atomically without either a mega-PR touching nine files or a shim. We wrote a thin adapter — a Provider that reads from the new Riverpod container and exposes the old interface — so call sites migrated independently, on their own PRs, in whatever order their owning engineer had bandwidth for. The shim was deleted only after the last call site moved, which for one provider took five weeks. That’s slower than a rewrite. It’s also the reason nothing broke.
Rollback triggers, decided before writing code
The part teams usually skip is defining, in advance, what “this migration is going wrong” looks like — so that “should we revert this provider” isn’t a judgment call made under pressure during an incident. We set three triggers per provider before migrating it, not after:
- Rebuild count regression. We instrumented widget rebuild counts on the top five screens before touching anything, then required the migrated provider to match or beat the baseline. Riverpod’s
selectshould make this a non-issue, but on one migration a poorly scopedref.watchcaused a 3x rebuild increase on a list screen — caught in a debug overlay before it shipped, not by a user complaint. - State divergence in dual-write mode. During the overlap window, both the old and new state holders received the same writes, and we asserted equality on every read in debug and staging builds. Any mismatch failed the build rather than silently shipping — this is what caught the auth provider’s stale-token edge case, which only appeared on a specific background-refresh race that manual QA never happened to trigger.
- Crash-free session rate, post-release. This was the only production-facing trigger, and the only one with a hard number attached to it as a release gate rather than a lint. If the metric dropped after a provider’s release, that provider — and only that provider, because they shipped independently — got reverted via the shim, which stayed in the codebase for exactly this reason until we were confident enough to remove it.
None of these triggers fired for a full revert. Two fired for in-flight fixes before release — which is the entire point of deciding thresholds ahead of time instead of relying on a code reviewer’s gut feeling about whether a diff “looks risky.”
What we’d tell a team about to do this
The unglamorous truth is that the migration pattern matters more than Riverpod itself. Swap in Bloc, or MobX, or a hand-rolled solution, and the same shape applies: rank by blast radius, shim the fan-out providers so call sites move independently, dual-write and diff before cutting over, and write down your rollback thresholds before you need them, not during the incident. The framework migration is the easy 20%. The discipline around sequencing and verification is the 80% that decides whether it ships quietly or becomes the reason nobody trusts the state layer for the next year.
This is the kind of work orithLabs does on existing Flutter codebases — not rewrites, but structural migrations and audits scoped tightly enough that a live app keeps shipping while its architecture improves underneath it. If you’re staring down a state-management migration on something with real users and real App Store reviews on the line, we’re happy to talk through the specific trade-offs for your codebase.