Steven's Knowledge

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.

SeverityItemsWhy
🔴 High — causes crashes, data loss, or memory leaks1, 4, 7, 8, 9, 13, 18, 24Production-blocking bugs
🟡 Medium — degrades UX or wastes resources6, 10, 11, 14, 16, 17, 20Performance / UX
🟢 Low — workflow friction, easy to spot once seen2, 3, 5, 12, 15, 19, 21, 22, 23, 25, 26Mostly 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:

ControllerMust 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

BehavioriOSAndroidFix
Hardware backNoneYesPopScope (Flutter 3.12+; WillPopScope is deprecated)
Status bar styleApp-controlledSystem-controlledSystemChrome.setSystemUIOverlayStyle
Safe areaNotch / Dynamic IslandUsually noneWrap with SafeArea
Scroll bouncebouncingclampingScrollPhysics or ScrollConfiguration
Font renderingSFRobotoSpecify a font explicitly
Time format24/12h follows systemSameUse 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

DifferenceImpact
Tree shakingCode reached only via reflection or dynamic loading is stripped
Assertions removedSide effects inside assert() disappear
Skia / Impeller switchiOS defaults to Impeller — possible rendering differences
Logs strippedprint 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:

  1. Delete ios/Podfile.lock and ios/Pods/, then run pod install --repo-update
  2. Upgrade CocoaPods: sudo gem install cocoapods
  3. Verify ios/Podfile's platform :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 UnsupportedError

For 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?"

On this page