setState After Dispose: Guard Async Callbacks
Check mounted before setState: async callbacks can fire after dispose when users navigate away.
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
- ✓A Flutter app with at least one StatefulWidget
- ✓Basics of async await and routes
- ✓flutter test running in your project
- setState called after dispose means an async callback fired after the State object was unmounted, usually because the user navigated away mid-request
- Guard every async continuation with if (!mounted) return before calling setState, since the await gap lets disposal happen first
- Cancel Timers, StreamSubscriptions, and AnimationControllers in dispose so their callbacks never reach a dead State
- Prefer FutureBuilder, StreamBuilder, or a state-management layer that owns the subscription lifecycle instead of raw callbacks
Imagine calling a restaurant to place an order, then moving to a new apartment before the food arrives. The delivery driver shows up at your old address and knocks on an empty door — that failed delivery is setState after dispose. Your widget ordered some data, the user navigated to another screen while it was loading, and when the data finally arrived there was no screen left to update.
It is the most common async crash in Flutter apps: setState() called after dispose(). You fetch a profile, the user taps back before the response lands, and the continuation calls setState on a State object the framework already tore down. In debug builds you get a loud exception; in production the orphaned callback can leak timers, streams, and controllers that keep running against a dead screen.
The trap is the await gap. Between starting an async operation and its completion, the framework makes no promises: the user can pop the route, switch tabs, or hot-reload the tree. Any code after the await runs in a world that may no longer contain your widget. Developers who learned setState before async patterns sprinkle it after every fetch, and each one is a latent crash waiting for a slow network and an impatient user.
This guide makes the lifecycle click. You will see exactly when dispose runs, why mounted is the only safe guard, how to cancel every callback source in dispose, and which patterns — FutureBuilder, StreamBuilder, disciplined controllers — remove whole classes of the bug. By the end, async-after-dispose crashes will read as obvious lifecycle mistakes, not mysteries.
The Lifecycle Behind the Error: Mount, Build, Dispose
A State object lives only while its element sits in the widget tree. initState runs once on insertion, build runs whenever the framework needs pixels, and dispose runs once on permanent removal — popping the route, switching tabs with state disposal, or rebuilding a parent that swaps widget types. After dispose returns, the element unmounts and the State becomes a husk: its context is invalid, its ticker is dead, and setState has no scheduler to talk to.
The mounted flag mirrors this lifetime exactly. It turns true before initState and false as dispose begins, never to turn true again. That makes it the only reliable question to ask after an await: am I still in the tree? Code before the first await in an event handler runs synchronously while mounted is guaranteed, but every line after an await runs later, in a world where anything — including disposal — may have happened.
Internalize one rule and most incidents vanish: no setState after an await without a mounted check. Read your State files with that lens and the bugs glow. Event handlers that fetch-then-set, timer callbacks that tick-then-set, stream listeners that receive-then-set — each needs either the guard or a lifecycle owner that dies with the widget.
The Async Gap: Why Navigation Beats Your Network Call
Dart awaits do not block the UI — that is the feature and the hazard. When your handler awaits a Future, the method suspends and control returns to the event loop, which keeps processing taps, including the back button. The route pops, dispose runs, the State unmounts. Seconds later the network answers, the method resumes exactly where it suspended, and setState fires into the void.
Slow networks widen the gap and fast users exploit it. A 200-millisecond response rarely loses the race; a 2-second p95 response loses constantly, especially on flows like search where users act on partial results. Debounced keystroke searches are the classic pile-up: five keystrokes launch overlapping requests, the user taps a result, and three stale continuations arrive after disposal. Each unguarded one throws.
Design for the loss, not the win. Assume every in-flight request will complete after disposal and make that outcome boring: guard with mounted, ignore stale responses by request id, or cancel the operation outright. The network owes you nothing — responses arrive when they arrive, and your widget may be long gone. Log dropped responses in debug builds so you can distinguish a cancelled race from a request that never fired.
Canceling Timers, Streams, and Controllers in Dispose
Mounted guards stop crashes but not leaks. A periodic Timer that fires setState every second will keep firing into your guard forever — no crash, but a dead State waking up pointlessly and a State object the garbage collector cannot free because the timer holds it. Multiply by every visit to the screen and you get climbing memory, wasted battery, and callbacks from three visits ago mutating shared services.
The rule is ownership: the State that creates a recurring source must kill it in dispose. Store Timers and StreamSubscriptions in fields, cancel them in dispose, and null the fields so double-dispose stays safe. Dispose AnimationControllers, TextEditingControllers, FocusNodes, and ScrollControllers you created — but never dispose controllers handed to you by a parent, since the owner disposes.
Order matters inside dispose. Cancel subscriptions and timers first so no callback can fire mid-teardown, dispose controllers next, and call super.dispose last. A callback that fires while half your fields are torn down produces weirder crashes than the original — so silence every source before dismantling anything. A dispose checklist taped to the team's review template beats rediscovering the order during every incident.
FutureBuilder and StreamBuilder: Let the Framework Own It
Hand-rolled fetch-then-setState is where the bug breeds, and FutureBuilder removes the breeding ground. You hand the framework a Future and a builder, and it handles subscription, rebuilds, and disposal: when the widget leaves the tree, the builder goes with it and late results have nowhere to land. No mounted check needed because there is no manual setState at all.
Use them with discipline. Create the Future once in initState and store it in a field — building a fresh Future inside build refires the request on every rebuild and spins forever. Handle all three snapshot states explicitly: waiting shows a spinner, error shows a retry, data shows content. A builder that only handles data will flash blank screens and swallow the errors you need to see.
StreamBuilder extends the same safety to live data like Firestore snapshots and location updates: it subscribes on mount and unsubscribes on dispose automatically. When the async work belongs to business logic rather than one screen, move it into a state-management layer whose lifetime exceeds any single route — but keep FutureBuilder as the default for screen-scoped loads. When reviewers see manual fetch-then-setState, they should ask why a builder was not enough.
Listeners, Controllers, and addPostFrameCallback Traps
Not every callback is a Future. AnimationController listeners, TextEditingController listeners, and ScrollController callbacks all fire synchronously from the framework — usually safe, but dangerous inside disposal itself or after a route pop races a fling animation. The same guard habit applies: any listener that calls setState should confirm the State is still mounted, especially listeners on objects shared across screens.
WidgetsBinding addPostFrameCallback is a subtler trap. Developers schedule a setState after the current frame to read sizes or show dialogs — but if the user pops the route before that frame runs, the callback fires on a disposed State. Guard post-frame callbacks with mounted too, or better, do the work in a place with a real lifecycle like didChangeDependencies.
Shared controllers deserve extra care. A PageController or audio player owned by a parent but listened to by a child must have the child's listener removed in the child's dispose — removing in the owner's dispose is too late if the child dies first. Whenever listener and owner have different lifetimes, the shorter-lived side must unsubscribe itself. Document listener ownership in a comment above every addListener so the next reader knows exactly who unsubscribes.
Proving Disposal Safety With Tests and DevTools
Lifecycle bugs hide from manual QA because testers rarely navigate with exact bad timing. Widget tests reproduce it deterministically: pump the screen, start the async load, pop the route before the Future completes, complete the Future, pump again, and assert tester.takeException is null. Use a Completer or a controllable fake so timing is exact rather than sleep-based — flaky timing tests get disabled, and disabled tests protect nothing.
DevTools catches the leak half of the problem. Open the memory view, push and pop the suspect screen ten times, force garbage collection, and count surviving State instances. Survivors mean a timer, subscription, or listener still holds them. The widget inspector's timeline also shows rebuilds: a disposed screen that keeps rebuilding is burning CPU against pixels nobody sees.
Make both checks permanent. A dispose-safety test per async screen plus a review checklist item — mounted after every await, cancellation in every dispose — turns a crash category into a solved problem. Teams that adopt the checklist watch after-dispose crashes disappear from their top-ten lists within a release or two. Publish the leak counts in the release notes so the whole team sees the trend.
Slow Search API Crashed 9% of Sessions After Back Navigation
dispose(), hitting 9 percent of sessions and concentrated on the search flow. The stack pointed at the search screen's result handler. Affected users saw a red error screen flash after pressing back from a result they had just opened — the worst moment to break trust, right after the app appeared to work. The backend was healthy and no deploy had touched search code that week, which sent the team hunting in the wrong place for half a day.- Treat every await as a navigation point: any code after it must assume the widget may be gone, so guard with mounted or restructure so stale results cannot reach setState.
- Backend latency is a client crash vector. A p95 jump from 400 milliseconds to 2 seconds turned a latent lifecycle bug into a 9-percent crash — track client crashes against backend latency on the same dashboard.
- Cancel recurring sources in dispose even when you add mounted guards. Guards stop the crash; cancellation stops the leak, the battery drain, and the next engineer's confusion.
dispose() with a stack into your screen| File | Command / Code | Purpose |
|---|---|---|
| io | class ProfileScreen extends StatefulWidget { | The Lifecycle Behind the Error |
| io | class SearchScreen extends StatefulWidget { | The Async Gap |
| io | class TickerScreen extends StatefulWidget { | Canceling Timers, Streams, and Controllers in Dispose |
| io | class ProfilePage extends StatefulWidget { | FutureBuilder and StreamBuilder |
| io | class SizeReader extends StatefulWidget { | Listeners, Controllers, and addPostFrameCallback Traps |
Key takeaways
Common mistakes to avoid
5 patternsCalling setState after every await with no mounted check
dispose() crashes that spike on slow networks, when users navigate away mid-request.Guarding with mounted but never canceling timers or streams
Creating the Future inside FutureBuilder's build method
Disposing a controller owned by someone else
Calling super.dispose() first in dispose
super.dispose() last as the final line.Interview Questions on This Topic
What does setState() called after dispose() mean?
Frequently Asked Questions
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
That's Flutter. Mark it forged?
5 min read · try the examples if you haven't