Twenty-three “random UI bugs” in the backlog. That’s where the last Flutter audit we ran started — a client with a ticket list full of vague reports like “sometimes the cart total doesn’t update” and “profile screen shows old data after logout.” No one on the team could reproduce most of them reliably, which is usually the first sign you’re not looking at twenty-three bugs. You’re looking at two or three root causes wearing different costumes.

That held up here too. Almost everything traced back to five specific shapes of state-management mistake, all of which are easy to write, easy to miss in review, and only show up under real usage patterns — backgrounding the app, navigating away mid-request, logging out without a full widget tree teardown. Here’s what we found, in the order we found it.

1. setState after the widget is gone

The classic. An async call — an API request, a Future.delayed, a plugin callback — resolves after the widget that started it has been disposed, and the completion handler calls setState() on a State object that no longer has a mounted BuildContext. Flutter doesn’t crash on this reliably in profile builds, which is exactly the problem: it fails silently in production and throws in debug, so it slips past casual testing.

The codebase had this in a dozen places, usually inside search-as-you-type handlers and image upload callbacks. The fix pattern is mechanical — guard with if (!mounted) return; before every setState in an async continuation — but the audit value wasn’t pointing that out. It was showing the team *why* it kept recurring: their API client wrapper returned raw Futures with no cancellation token, so every screen that fired a request had to remember the guard manually, and nobody did it consistently. The actual fix was upstream, in the networking layer, not in twelve individual widgets.

2. Provider scope mismatches

The second cluster came from Provider (and by extension, Riverpod configured the same way) being registered at the wrong level of the widget tree. A cart notifier declared with `ChangeNotifierProvider` above the `MaterialApp` looked fine until you noticed that the login/logout flow rebuilt the navigator but not the provider tree above it — so a logged-out user’s widget tree was still reading a `CartNotifier` instance instantiated for the previous session. The “profile shows old data after logout” ticket was this, exactly: the provider outlived the identity it was scoped to.

The general rule that came out of it: a provider’s lifetime should match the lifetime of the data it holds, not the lifetime of the app. Session-scoped state needs to live under a widget that gets torn down and rebuilt on login/logout — not at the root. This is a design decision, not a syntax fix, and it’s the kind of thing that’s invisible in a PR diff because the provider registration line looks completely reasonable in isolation. You only see the mismatch by tracing the tree.

3. Stale StreamBuilder subscriptions

The third pattern was subtler and probably accounted for the largest share of “sometimes” bugs. A `StreamBuilder` was built with a stream expression computed inline — something like StreamBuilder(stream: FirestoreService.ordersFor(userId), ...) — where `userId` came from a provider that could change (again, around login/logout, or account switching). Every rebuild of the parent widget re-evaluated that expression and created a *new* stream subscription, without Flutter necessarily disposing of the old one on the same frame. For a short window, two subscriptions were live, and whichever one delivered its snapshot last “won,” which is indistinguishable from random behavior to whoever’s watching the screen.

The fix is to hoist the stream into state — create it once (in `initState` or a memoized provider), not inline in `build()` — so the subscription identity is stable across rebuilds. This is one of those bugs that’s genuinely hard to catch by reading code, because the incorrect version and the correct version look almost identical on the page. You catch it by asking “what triggers a rebuild of this widget, and does that also trigger a new stream instance?” for every StreamBuilder in the app.

Two more worth naming

  • Listener leaks: `addListener` calls on `ChangeNotifier`, `AnimationController`, or `TextEditingController` without a matching `removeListener` in `dispose()`. These don’t crash; they just accumulate, so screens visited many times over a session slow down and start firing callbacks against widgets that are long gone.
  • Rebuild scope too wide: a single `Consumer` or `context.watch` wrapping an entire screen instead of the specific subtree that needs the data, so every state change repaints far more than intended. Not a correctness bug on its own, but it masks the other four — when everything rebuilds constantly, it’s much harder to tell which rebuild actually caused the bad behavior you’re chasing.

What this means for how we audit

None of these five require exotic tooling to find — they require reading the app the way it actually gets used (background it, kill a request mid-flight, log out mid-navigation) rather than the way it gets demoed. That’s the difference between a code review that checks style and an audit that’s trying to explain twenty-three tickets. We go in assuming the bug reports are symptoms, group them by underlying mechanism first, and only then start proposing fixes — because a fix applied to one symptom without understanding the mechanism usually leaves the other five tickets unsolved.

This is the kind of work we do at orithLabs when a team brings us an existing Flutter codebase rather than a blank repo — whether that’s a full architectural audit or just getting a second set of eyes on a specific class of bug before it ships further. If your bug tracker has its own pile of “sometimes” tickets, there’s a decent chance they cluster the same way.