In Part 1, we argued that standard linters catch style, while static analysis catches behavior.
This article is the proof.
Flutter anti-patterns are code patterns that compile successfully but fail at runtime. Below is a catalog of the 10 most common “silent killers” that flutter analyze ignores:
- Crashes: FutureBuilder Refires, Async Gaps
- Leaks: Listener Leaks, Timer Zombies
- Performance: Opacity Traps, Getter Instantiation Loops, Unconstrained Images
- Logic & Security: Mutable hashCodes, Swallowed Errors, Logging Secrets
These are logical time bombs.
1. The FutureBuilder Refire Trap
The Symptom: Your app makes duplicate network requests every time the keyboard opens or the screen rotates.
The Code:
// ❌ BAD: Future creates a new instance on every build
class UserProfile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: api.getUser(id: '123'), // <--- Called repeatedly
builder: (ctx, snapshot) => Text(snapshot.data?.name ?? 'Loading'),
);
}
}
The Mechanics of Failure: FutureBuilder subscribes to the specific instance of the Future provided. When build() runs (e.g., during a keyboard animation), api.getUser() is called again, creating a new Future instance. The builder discards the old data, shows the loading spinner again, and spams your backend API.
The Fix: Cache the Future in initState or a State Management provider.
// ✅ GOOD: Future instance persists across rebuilds
class _UserProfileState extends State {
late final Future _userFuture;
@override
void initState() {
super.initState();
_userFuture = api.getUser(id: '123');
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _userFuture, // <--- Stable instance
builder: (ctx, snapshot) => ...
);
}
}
2. The BuildContext Async Gap
The Symptom: Weird crashes or dialogues appearing on the wrong screen.
The Code:
// ❌ BAD: Using Context across an async gap
void _onLogin() async {
await auth.login();
// If the user navigates away while logging in...
Navigator.of(context).pop(); // ...this context is now detached or stale.
}
The Mechanics of Failure: BuildContext is tied to a specific location in the Element tree. If you await a long operation, the widget might be unmounted (removed from the tree) by the time the await finishes. Using a detached context to find an Ancestor (like Navigator or Theme) throws a runtime exception.
The Fix: Check mounted before using context after an async gap.
// ✅ GOOD: Ensure context is valid
void _onLogin() async {
await auth.login();
if (!context.mounted) return; // <--- MODERN STANDARD
Navigator.of(context).pop();
}
3. The Controller Listener Leak
The Symptom: The app gets slower the longer it is used.
The Code:
// ❌ BAD: Adding a listener without removing it
class _MyState extends State {
@override
void initState() {
super.initState();
widget.scrollController.addListener(_onScroll);
}
// Missing dispose() or removeListener()
}
The Mechanics of Failure: When you add a listener to a long-lived object (like a global ChangeNotifier or a ScrollController passed from a parent), you create a strong reference from that object back to your widget's methods. Even if your widget is removed from the screen, the parent object holds onto the listener, preventing the Garbage Collector from cleaning up your widget.
The Fix: Always mirror addListener with removeListener.
// ✅ GOOD: Cleanup
@override
void dispose() {
widget.scrollController.removeListener(_onScroll);
super.dispose();
}
4. The Timer Zombie
The Symptom: Code executes and tries to update UI on screens the user has already closed.
The Code:
// ❌ BAD: Timer continues after widget death
void startCountdown() {
Timer.periodic(Duration(seconds: 1), (timer) {
setState(() => _seconds--); // Crash if unmounted
});
}
The Fix: Store the Timer instance and cancel it in dispose.
// ✅ GOOD: Cancel the timer
Timer? _timer;
void startCountdown() {
_timer = Timer.periodic(Duration(seconds: 1), (_) {
setState(() => _seconds--);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
5. The Opacity Widget Trap
The Symptom: Frame drops during animations involving fading.
The Code:
// ❌ BAD: Expensive compositing for simple transparency
Opacity(
opacity: 0.5,
child: Container(color: Colors.red),
)
The Mechanics of Failure: The Opacity widget is expensive because it requires Flutter to render the child to an intermediate buffer (saveLayer), apply the alpha blend, and then paint it back. This breaks the rendering pipeline's efficiency.
The Fix: If you just need a transparent color, use the color’s alpha channel. If you need to animate opacity, use FadeTransition (which is GPU-optimized).
// ✅ GOOD: Cheap alpha blending
Container(color: Colors.red.withOpacity(0.5))
6. The getter Instantiation Loop
The Symptom: Lists or UI elements flickering or rebuilding unnecessarily.
The Code:
// ❌ BAD: Returns a NEW object every time it's accessed
List get items => ['A', 'B', 'C'];
@override
Widget build(BuildContext context) {
// Selector/Provider checks equality:
// ['A'] == ['A'] is FALSE in Dart (different instances)
// Triggers unnecessary rebuilds.
return MyList(items: items);
}
The Mechanics of Failure: In Dart, two lists with the same content are not equal ([1] != [1]). If you use a getter to return a list literal, State Management tools (like Provider or Bloc) will think the data has changed every single time, triggering infinite rebuild loops or wasted render cycles.
The Fix: Use const (if possible) or late final fields.
// ✅ GOOD: Constant instance, never changes
static const List items = ['A', 'B', 'C'];
@override
Widget build(BuildContext context) {
return MyList(items: items);
}
“The most effective debugging tool is still careful thought, coupled with judiciously placed print statements.” — Brian Kernighan
7. The Mutable hashCode
The Symptom: Objects disappearing from Sets or failing lookup in Maps.
The Code:
// ❌ BAD: HashCode depends on mutable fields
class User {
String name; // Mutable
User(this.name);
@override
bool operator ==(Object other) => other is User && other.name == name;
@override
int get hashCode => name.hashCode;
}
The Mechanics of Failure: If you put this User into a HashSet or Map, it is placed in a "bucket" based on its hash. If you later change the name, the hash changes. When you try to find the object later, the Set looks in the new hash bucket, doesn't find it, and tells you the object doesn't exist—even though it's right there.
The Fix: Fields used in == and hashCode should always be final.
// ✅ GOOD: Immutable fields
@immutable
class User {
final String name;
const User(this.name);
@override
bool operator ==(Object other) => other is User && other.name == name;
@override
int get hashCode => name.hashCode;
}
8. Catching Error instead of Exception
The Symptom: The app freezes or behaves unpredictably instead of crashing, making debugging impossible.
The Code:
// ❌ BAD: Catching everything swallows critical failures
try {
doSomething();
} catch (e) {
print(e);
}
The Mechanics of Failure: In Dart, Exception is for planned errors (Network failed). Error is for code bugs (Out of Memory, Stack Overflow). By using catch (e), you catch everything, including things the VM should crash on. You mask the root cause and leave the app in an unstable state.
The Fix: Catch specific exceptions or use on Exception catch (e).
// ✅ GOOD: Catches only planned failures
try {
doSomething();
} on Exception catch (e) {
print('Handled exception: $e');
}
9. Logging Sensitive Data
The Symptom: User passwords or auth tokens appearing in crash reports or system logs.
The Code:
// ❌ BAD: PII in production logs
print('User logged in: ${user.email}');
// ✅ GOOD: Log events, not data (or redact it)
print('User logged in: (redacted)');
// Or use a logger that strips PII in release mode
log.info('Auth flow completed');
The Mechanics of Failure: On Android, print() often goes to logcat, which other apps (with permission) or USB-connected devices can read. If you log tokens, you are leaking sessions.
The Fix: Use a logger that strips sensitive data in release mode, or specialized rules to flag variables named password/token inside interpolation strings.
10. The Unconstrained Web Image
The Symptom: Layout shifts or Denial of Service (OOM) from large images.
The Code:
// ❌ BAD: Loading network images without limits
Image.network(userUrl);
The Mechanics of Failure: If a user uploads a 40MB, 8000x8000 pixel image as their avatar, Image.network will try to decode the whole thing into memory. On a mobile device, this causes a massive memory spike and can crash the app (OOM).
The Fix: Use cacheWidth / cacheHeight to tell the engine to decode the image at a smaller size.
// ✅ GOOD: Decode only what is needed
Image.network(
userUrl,
cacheWidth: 300, // Decodes to a reasonable list-item size
);
This is a Checklist, Not a Strategy
You cannot memorize all of these. And you shouldn’t try.
If you are relying on manual code review to catch “Mutable HashCodes” or “Async Context Gaps,” you are fighting a losing battle. These patterns are subtle, valid Dart code.
The only way to win is to automate.
In Part 3, we will introduce the tooling configuration that acts as a dragnet for these 10 patterns…. and more than 1,700 others!
“First, solve the problem. Then, write the code.” — John Johnson
Sources and Further Reading
- Dart Async/Await — Asynchronous programming https://dart.dev/libraries/async/async-await
- Provider package — State management solution https://pub.dev/packages/provider
- StreamSubscription — Managing stream listeners https://api.dart.dev/stable/dart-async/StreamSubscription-class.html
- Lazy Loading in Flutter — Efficient list rendering https://docs.flutter.dev/ui/widgets-intro#bringing-it-all-together
Final Word 🪅
