Pitfalls
Common Flutter pitfalls — BuildContext, setState, memory leaks, platform differences, and more
Pitfalls
Real production pitfalls organized as symptom → root cause → fix so you can find the relevant entry quickly while debugging.
Severity At-a-Glance
If you only have time to internalize a handful, focus on the high-severity ones first.
| Severity | Items | Why |
|---|---|---|
| 🔴 High — causes crashes, data loss, or memory leaks | 1, 4, 7, 8, 9, 13, 18, 24 | Production-blocking bugs |
| 🟡 Medium — degrades UX or wastes resources | 6, 10, 11, 14, 16, 17, 20 | Performance / UX |
| 🟢 Low — workflow friction, easy to spot once seen | 2, 3, 5, 12, 15, 19, 21, 22, 23, 25, 26 | Mostly papercuts |
BuildContext
1. Using context after an async gap
Symptom: Looking up a deactivated widget's ancestor is unsafe, or a hard crash.
Root cause: After await, the current widget may already be disposed, but context still points at the unmounted Element.
// BUG
Future<void> save() async {
await api.save();
Navigator.pop(context); // widget may be unmounted
}
// FIX
Future<void> save() async {
await api.save();
if (!mounted) return;
Navigator.pop(context);
}Enable the use_build_context_synchronously lint.
2. Calling of(context) inside initState
Symptom: InheritedWidget.of() called before the widget is registered, or you do not receive InheritedWidget data.
Root cause: During initState, the parent InheritedWidget dependency is not yet registered.
// BUG
@override
void initState() {
super.initState();
final theme = Theme.of(context); // returns a value, but no subscription
}
// FIX: move dependencies on outer data into didChangeDependencies
@override
void didChangeDependencies() {
super.didChangeDependencies();
final theme = Theme.of(context);
}3. Wrong context for dialogs / SnackBars
Symptom: No MaterialLocalizations found, or the dialog never appears.
Root cause: Used a context above MaterialApp (for example the root context passed to runApp).
// FIX: use a page-level context, or a GlobalKey<NavigatorState>
final scaffoldKey = GlobalKey<ScaffoldMessengerState>();
MaterialApp(scaffoldMessengerKey: scaffoldKey, ...);
// Trigger from anywhere:
scaffoldKey.currentState?.showSnackBar(SnackBar(content: Text('done')));setState
4. setState after dispose
Symptom: setState() called after dispose().
Root cause: Timer / Stream / Future callbacks fire without checking mounted.
// BUG
Timer.periodic(Duration(seconds: 1), (_) {
setState(() => count++); // still fires after the widget is disposed
});
// FIX 1: cancel in dispose
late final Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(Duration(seconds: 1), (_) {
if (mounted) setState(() => count++);
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}5. setState during build
Symptom: setState() or markNeedsBuild() called during build.
Root cause: Mutating state synchronously on the build path causes infinite rebuilds.
// BUG
@override
Widget build(BuildContext context) {
if (data == null) {
setState(() => data = compute()); // wrong
}
return Text(data ?? '');
}
// FIX: do async work in initState; if you must defer, schedule for next frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => data = compute());
});6. Heavy synchronous work inside setState
Symptom: UI hitches and jank.
Root cause: The setState callback runs synchronously on the UI thread; expensive work blocks rendering.
// BUG
setState(() {
result = heavyCompute(); // blocks the UI
});
// FIX
final r = await compute(heavyCompute, input); // run in an isolate
if (mounted) setState(() => result = r);Memory Leaks
7. Forgetting to dispose Controllers
The most common source of leaks:
| Controller | Must dispose |
|---|---|
TextEditingController | ✅ |
ScrollController | ✅ |
AnimationController | ✅ |
StreamController | ✅ |
FocusNode | ✅ |
PageController | ✅ |
class _MyState extends State<MyWidget> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}8. Forgetting to cancel a StreamSubscription
late final StreamSubscription _sub;
@override
void initState() {
super.initState();
_sub = stream.listen((event) => ...);
}
@override
void dispose() {
_sub.cancel();
super.dispose();
}9. Global singletons holding BuildContext / State
Symptom: Memory does not drop after the page is disposed.
Root cause: A context or State instance was stashed into a global Service or Singleton.
Fix: Global objects should hold only data and callbacks. Pass context in transiently, decouple via WeakReference or an event bus.
ListView / Scrolling
10. Nested ListView without shrinkWrap
Symptom: Vertical viewport was given unbounded height.
Root cause: An inner ListView cannot determine its height inside a parent with unbounded height.
// FIX 1: shrinkWrap + NeverScrollableScrollPhysics on the inner ListView
ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [...],
)
// FIX 2 (recommended): use CustomScrollView + Sliver composition
CustomScrollView(slivers: [SliverList(...), SliverGrid(...)])shrinkWrap: true defeats lazy loading — performance degrades sharply with many items. Production-grade large lists must use Slivers.
11. Building a ListView without builder
Symptom: Slow initial render and high memory for long lists.
Root cause: ListView(children: [...]) constructs every child eagerly; only ListView.builder lazy-loads.
// BUG
ListView(children: items.map((e) => Tile(e)).toList())
// FIX
ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => Tile(items[i]),
)12. Scrolling to a specific item is hard
Root cause: Flutter lists do not support "scroll to index N" out of the box because child heights are unknown.
Fix: Use scrollable_positioned_list, or fix itemExtent and call ScrollController.jumpTo(index * itemExtent).
Images
13. Loading large images causes OOM
Symptom: App crashes (especially on Android) after loading a few large images.
Root cause: Image.network decodes at the original resolution by default; a 4032×3024 photo consumes ~46 MB of memory.
// FIX: cap the decode size
Image.network(
url,
cacheWidth: 800, // scale during decode to save memory
cacheHeight: 600,
)
// Or use ResizeImage
Image(image: ResizeImage(NetworkImage(url), width: 800))14. No image cache strategy
Root cause: Default Image.network does not perform disk caching — only an in-memory ImageCache.
Fix: Use cached_network_image — built-in disk cache, placeholder, and error fallback.
Platform Differences
15. iOS and Android behave differently
| Behavior | iOS | Android | Fix |
|---|---|---|---|
| Hardware back | None | Yes | PopScope (Flutter 3.12+; WillPopScope is deprecated) |
| Status bar style | App-controlled | System-controlled | SystemChrome.setSystemUIOverlayStyle |
| Safe area | Notch / Dynamic Island | Usually none | Wrap with SafeArea |
| Scroll bounce | bouncing | clamping | ScrollPhysics or ScrollConfiguration |
| Font rendering | SF | Roboto | Specify a font explicitly |
| Time format | 24/12h follows system | Same | Use MediaQuery.alwaysUse24HourFormat |
16. Android keyboard hides input fields
Root cause: Scaffold.resizeToAvoidBottomInset defaults to true, but if explicitly set to false, or the form lives inside a non-scrollable container, fields end up obscured.
// FIX: wrap the form in a SingleChildScrollView
Scaffold(
body: SingleChildScrollView(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Column(children: [TextField(), TextField()]),
),
)Async
17. FutureBuilder re-fires when the parent rebuilds
Symptom: API is called repeatedly on list scroll or theme change.
Root cause: FutureBuilder(future: api.fetch(), ...) creates a new Future on every rebuild.
// BUG
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: api.fetch(), // new request on every rebuild
builder: ...,
);
}
// FIX: cache the Future in State
late final Future<Data> _future = api.fetch();
@override
Widget build(BuildContext context) {
return FutureBuilder(future: _future, builder: ...);
}The same applies to StreamBuilder.
18. Swallowing exceptions in async code
// BUG: silent failure
Future<void> save() async {
try {
await api.save();
} catch (e) {
// not logged, not reported, user not notified
}
}
// FIX
try {
await api.save();
} catch (e, st) {
log('save failed', error: e, stackTrace: st);
Sentry.captureException(e, stackTrace: st);
if (mounted) showError(context, e);
rethrow; // or skip rethrow, but be deliberate
}Build and Release
19. Release mode behaves differently from debug
| Difference | Impact |
|---|---|
| Tree shaking | Code reached only via reflection or dynamic loading is stripped |
| Assertions removed | Side effects inside assert() disappear |
| Skia / Impeller switch | iOS defaults to Impeller — possible rendering differences |
| Logs stripped | print still runs in release; debugPrint does not always |
Recommendation: use flutter run --profile daily to validate performance; smoke-test the release build on a real device before shipping.
20. Bundle size grows out of control
Investigation: flutter build apk --analyze-size or flutter build appbundle --analyze-size.
Common causes:
- Pulled in oversized fonts (CJK fonts can be several MB)
- Images not compressed, redundant density variants shipped
- Depends on a large native library (ML Kit, PDF rendering)
- R8 / ProGuard obfuscation not enabled
21. iOS pod dependency conflicts
Symptom: pod install fails or versions cannot be resolved.
Fix:
- Delete
ios/Podfile.lockandios/Pods/, then runpod install --repo-update - Upgrade CocoaPods:
sudo gem install cocoapods - Verify
ios/Podfile'splatform :ios, '12.0'satisfies every plugin's requirement
Hot Reload Pitfalls
22. Hot reload does not trigger a rebuild
Root causes:
- Modified
main()— hot restart required - Modified an enum definition or top-level variable initializer
- Modified native code (Kotlin / Swift) — full reinstall required
Press R (uppercase) for a hot restart, or stop and rerun.
23. Hot reload state is inconsistent
StatefulWidget State is preserved across hot reload. If you changed field types or added required fields, results may look "not applied". When in doubt, hot restart.
Dart Language Pitfalls
24. == and hashCode must be overridden together
Symptom: Set/Map deduplication breaks; state comparisons fail.
class Item {
final int id;
Item(this.id);
@override
bool operator ==(Object other) => other is Item && other.id == id;
@override
int get hashCode => id.hashCode; // mandatory!
}Prefer freezed to generate these automatically.
25. Non-const collection literals are mutable
A non-const [] or {} literal creates a fresh growable instance every time it executes — final only freezes the binding, not the collection's contents.
// `final` prevents reassignment, but the list itself is still mutable
final items = [1, 2, 3];
items.add(4); // works
// A `const` literal is canonicalized and deeply immutable
final items = const [1, 2, 3];
items.add(4); // throws UnsupportedErrorFor API return values, expose List.unmodifiable() or use built_collection / freezed collections to enforce immutability at the boundary.
26. Missing await in a Future chain
// BUG: caller does not actually wait for the inner Future
Future<void> save() async {
api.save(); // no await! caller assumes save is done
}
// FIX
Future<void> save() async {
await api.save();
}Enable the unawaited_futures lint.
Overarching principle: most Flutter pitfalls trace back to misunderstanding "rebuild timing" and "lifecycle". Make it a habit — every time you write a callback, ask yourself: "When this fires, does the widget still exist? Which resources do I need to release?"