Live match scoring in Kompete has one property that breaks most Flutter state tutorials: two people can update the same score within 200 milliseconds of each other, over cellular connections with jitter, while a third screen is watching a read-only scoreboard that has to animate smoothly instead of jump-cutting between values. We rebuilt the state layer under this feature three times. Each rewrite wasn’t a style preference, it was a specific class of bug that the previous architecture couldn’t express a fix for without becoming something else entirely.

Attempt one: setState and the optimistic update that lied

The first version was what you’d expect from a scoring screen: a StatefulWidget holding the match score, a tap handler that incremented it locally, and a Firestore write fired off in the background. Optimistic UI, standard stuff.

It fell apart because “optimistic” implicitly assumes your write eventually reconciles with a single source of truth, and we had two independent optimistic writers (both team captains can adjust the score) with no ordering guarantee. The failure mode was concrete: captain A taps +1 at 10.201s, captain B taps +1 at 10.240s on a stale local copy that hadn’t yet received A’s update. Both set local state to score+1. Both writes land in Firestore. The listener fires twice, and depending on snapshot ordering, the UI either double-counts or silently drops one point. We had no seam to insert conflict resolution because the widget’s setState calls were the only place score mutation happened, and they happened before either write was acknowledged.

Patching this inside setState meant threading a “pending write” flag and a reconciliation diff through a StatefulWidget’s lifecycle methods, which is the point where you’re rebuilding a state container by hand inside a widget and should stop.

Attempt two: Provider and the rebuild storm

We moved score state into a ChangeNotifier exposed via Provider, with a single MatchScoreService as the write-through cache in front of Firestore. This fixed the double-write problem — writes went through one path, and the service applied a last-write-wins resolution keyed on server timestamp. Correct, finally.

What it didn’t fix was rebuild scope. The MatchScoreService held score, elapsed time, possession, and fouls in one ChangeNotifier, because at the time that felt like “one match, one state object.” Every notifyListeners() — including the once-a-second clock tick — rebuilt every Consumer in the tree, including the score digits that hadn’t changed and a possession-arrow animation that restarted every tick because AnimatedContainer saw a new build and reinterpreted it as a fresh transition. On a mid-range Android device this showed up as visible jank during exactly the moments (a scoring play) when the UI most needed to stay smooth. Splitting into multiple ChangeNotifiers with Selector was the “correct Provider way” out, but at that point we were manually managing a dependency graph that a proper state library gives you for free.

Where Riverpod entered, and where it also fell over

Riverpod’s provider-per-slice model solved the rebuild storm directly — scoreProvider, clockProvider, and possessionProvider as separate StateNotifierProviders meant a clock tick only rebuilt the clock widget. But the first Riverpod pass reintroduced a subtler version of the original race: the scoreProvider’s StateNotifier had an async updateScore() method that read current state, computed a new value, and wrote it, with no guard against two updateScore() calls interleaving on the same isolate’s event loop while awaiting the Firestore call. Dart is single-threaded, but await yields control, and a second tap during that yield read the pre-write state. Same bug as attempt one, just moved one layer down and harder to spot because it now hid behind async/await instead of an obviously naive setState.

What actually stopped the fighting

The fix wasn’t a fourth state management library. It was treating score mutation as a serialized command queue rather than a value you mutate optimistically from multiple call sites. The final shape: a Bloc (chosen mainly for its explicit event-in, state-out contract, which makes serialization visible in code review rather than implicit in await ordering) where every score change is an event appended to a queue, processed one at a time, each event carrying a monotonic client-sequence number. The Firestore listener emits a separate event type when the server’s authoritative value diverges from the locally predicted one, and the Bloc reconciles by sequence number instead of by “whichever write landed last.” Local UI updates are still instant — no user-perceptible latency — but they’re instant predictions of a serialized queue, not independent mutations racing each other.

The concrete lesson wasn’t “Bloc beats Riverpod” — Riverpod would have worked fine with the same serialization discipline applied to the StateNotifier. The lesson was that no state management library removes the need to identify where concurrent writers touch shared mutable state and to force those writers through a single ordered path. setState hid that requirement, Provider surfaced it only after a performance regression made us look, and early Riverpod let us write the bug again in async clothing. Bloc’s event contract simply made the missing serialization impossible to skip past in review.

If you’re debugging something that smells like this — a race that only shows up under real network jitter, or a rebuild storm that only appears on mid-tier hardware — that’s the kind of failure mode we spend a lot of our audit work on at orithLabs. Kompete and Crumb Count are both running the patterns described here in production, not as a slide deck; happy to walk through the actual code if it’s useful.