Debugging
Flutter debugging toolchain — DevTools, observatory, breakpoints, layout inspection, crash reporting
Flutter Debugging
Flutter debugging is unusual: the same app runs on iOS, Android, web, and desktop, and each target has different tooling for layout, performance, and native interop. A senior engineer's job is to know which tool to reach for at which moment, not to memorize every flag.
For the language-agnostic debugging discipline (reproduce, shrink, hypothesize, verify), see Debugging under code-craft. This page covers Flutter-specific tools.
The Toolchain
| Tool | What it's for |
|---|---|
flutter run --verbose | Build / install / hot-reload diagnostics |
| Flutter DevTools | Inspector, performance, memory, network, logging, CPU profiling |
dart:developer log() and debugger() | Structured logs and programmatic breakpoints |
debugPrint | Throttled, large-payload-safe print |
| Observatory / VM Service | Underlying protocol DevTools uses; rarely accessed directly |
| Crash reporting (Sentry, Crashlytics) | Production stack traces with symbolication |
| Native debuggers (Xcode, Android Studio) | Native crashes, MethodChannel, platform views |
The two daily-driver tools: DevTools and Xcode/Android Studio when DevTools can't reach native.
DevTools
Start DevTools attached to a running app:
flutter pub global activate devtools # one-time
flutter run # in your project
# Visit the URL printed by `flutter run`, or:
dart devtoolsDevTools is browser-based, single-page, and works across iOS/Android/web. The tabs:
Inspector
The widget tree, live. The most useful Flutter debugging tool by a wide margin.
- Select Widget Mode — click anywhere in the running app, jump to the widget in the tree and the source line.
- Show Guidelines — overlays paint, baseline, and constraint visualization on the running app.
- Highlight Repaints — flashes regions that repaint each frame. The flag for "rebuild scope is wrong."
- Highlight Oversized Images — flashes images decoded at far larger than display size. The flag for "memory waste."
When a layout is wrong, open the Inspector before reading code. The visual answer is usually faster than the code one.
Performance
Frame timeline, GPU thread, raster thread. The key metric: are frames building under 16.6 ms (60 fps) or 8.3 ms (120 fps)?
Read order:
- UI thread budget exceeded. Build/layout/paint took too long — Dart work in
buildor an expensive widget tree. - Raster thread budget exceeded. GPU work too heavy — too many layers, large images, expensive shaders.
- Shader compilation jank. First-time use of a shader. Mitigate with
flutter build --target-platform=android-arm64 --bundle-sksl-path=...to pre-compile.
The Timeline Events tab shows what kind of work consumed the budget. Don't optimize without it; you'll guess wrong.
Memory
Allocation tracking, leaks, snapshots. The flow:
- Take a snapshot. Note baseline.
- Perform the suspect operation (open and close a screen many times).
- Take another snapshot.
- Diff. Objects that should have been collected but weren't are leaks.
The common Flutter leaks:
- A
StreamSubscription,AnimationController, orTextEditingControllernot disposed indispose(). - A static reference holding onto a
Stateobject. - A closure captured by a long-lived listener that references a widget.
Network
Captures HTTP traffic from dart:io and package:http. Shows request/response headers, body, timing. Catches:
- Wrong endpoint or method.
- Misformatted JSON.
- Cache-control or auth header issues.
- Slow endpoints.
Does not capture WebSocket frames or package:dio traffic by default — those need an interceptor that emits dart:developer TimelineTask events.
Logging
Live tail of dart:developer log() calls. Better than print for anything beyond casual debugging — supports levels, named loggers, error objects, and stack traces.
import 'dart:developer' as developer;
developer.log(
'Order placed',
name: 'app.checkout',
level: 800, // INFO
error: error,
stackTrace: stackTrace,
);The name parameter lets you filter; the level parameter (using dart:developer numeric levels, or logging package constants) lets you set verbosity.
Layout Debugging
Flutter's "infinite size" and "unbounded constraint" errors are the most common layout puzzles.
The two cases
"RenderBox was not laid out" — usually a child that expects bounded constraints (e.g., Column containing an Expanded) is placed inside an unbounded parent (e.g., SingleChildScrollView). Fix by adding a SizedBox, ConstrainedBox, or IntrinsicHeight at the right level.
"Vertical viewport was given unbounded height" — a ListView inside a Column without Expanded. Wrap the ListView in Expanded or give it a fixed height.
debugDumpRenderTree
When the Inspector is too noisy, dump the render tree to stdout:
import 'package:flutter/rendering.dart';
WidgetsBinding.instance.addPostFrameCallback((_) {
debugDumpRenderTree();
});Shows actual sizes, constraints, and offsets — the resolved values, not the widget descriptions.
Constraint flow
Flutter layout is "constraints go down, sizes go up, parent sets position." A widget that renders wrong almost always has one of:
- Wrong constraints from parent (parent is unbounded, or too tight).
- Wrong size choice given constraints (clamped, ignored, or default).
- Wrong position from parent (centering, alignment, padding).
The Inspector's "Show Guidelines" overlay shows this directly.
Hot Reload vs Hot Restart
Hot reload (r) — re-runs build methods with new code, preserving state. The default.
Hot restart (R) — restarts the app, preserving the running process. State is lost; main() runs again.
Hot reload fails silently on:
- Top-level state (changes to
finalorconstvariables). - Initialization logic in
initState. - Native code changes (Kotlin, Swift, Objective-C, plugins).
- App lifecycle hooks (
WidgetsBindingObserver).
When in doubt after a confusing reload, hot restart. After native changes, full flutter run.
When Hot Reload Lies
Symptoms of stale state:
- Layout looks correct in code but wrong on screen.
- A widget tree change is reflected but its initial state is old.
- An animation starts from an unexpected value.
The fix:
- Hot restart.
- If still wrong,
flutter clean && flutter run. - If still wrong, the bug is real, not stale.
The "always hot restart before deciding it's a bug" reflex saves time. The cost is two seconds; the alternative is debugging stale state.
Breakpoints
In IDE (VS Code or Android Studio), set breakpoints in the editor gutter. The Dart debugger handles async code correctly — await resumes at the right place.
Programmatic breakpoints with dart:developer:
import 'dart:developer';
void onTap() {
debugger(when: someCondition); // breaks only when the condition is true
// ...
}Conditional breakpoints in the IDE work but are slow on hot paths — the debugger evaluates the condition on every hit. For tight loops, prefer debugger(when: ...) in code.
Native Debugging
Flutter UI debugging is in DevTools; anything below the engine is in the native IDE.
iOS
Open ios/Runner.xcworkspace in Xcode (not .xcodeproj). Run the app from Xcode to:
- Set breakpoints in Swift/Objective-C platform code.
- Use Xcode's memory graph debugger for retain cycles in native plugins.
- Profile with Instruments for native-side performance.
- Read iOS system logs via Console.app.
For native crashes, the symbolicated crash report from TestFlight/Xcode Organizer is the primary tool. Flutter ships dSYMs as part of the iOS build artifacts; ensure they're uploaded to your crash reporter.
Android
Open android/ in Android Studio. Run the app from there to:
- Set breakpoints in Kotlin/Java platform code.
- Use the Memory Profiler for native heap.
- Profile with the CPU Profiler for native-side performance.
- Read Logcat for system messages.
For native crashes, ProGuard mappings must be uploaded to your crash reporter (Crashlytics, Sentry). Without them, stack traces show obfuscated symbols.
MethodChannel
The most common Flutter↔native bug shape: a MethodChannel call that fails silently.
Diagnostic checklist:
- Channel name matches exactly on both sides (case-sensitive).
- Method name matches exactly.
- Argument types serialize correctly. Dart
int→ platformInt/NSNumber. DartMap→Map<String, Any?>on Kotlin,[String: Any]on Swift. - Call is on the correct thread. Platform code that touches UI must be on the main thread.
- Errors are propagated. Native side should call
result.error()for failures, not just fail to call anything.
Wrap MethodChannel calls in try/catch on the Dart side; log both success and failure paths.
Crash Reporting
Production debugging starts with crashes you can see. Tooling:
- Sentry — most common for cross-platform; supports source map upload for symbolication.
- Firebase Crashlytics — first-party, mobile-only, free.
- Bugsnag — paid alternative with good Flutter support.
The setup pattern:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await SentryFlutter.init(
(options) {
options.dsn = 'https://...';
options.tracesSampleRate = 0.1;
},
appRunner: () => runApp(const MyApp()),
);
}Critical: upload debug symbols (iOS dSYMs and Android ProGuard mappings) to the crash reporter as part of the build pipeline. Without them, stack traces are useless.
Also forward Flutter framework errors:
FlutterError.onError = (details) {
Sentry.captureException(details.exception, stackTrace: details.stack);
};
PlatformDispatcher.instance.onError = (error, stack) {
Sentry.captureException(error, stackTrace: stack);
return true;
};The first catches FlutterError (build/layout errors). The second catches uncaught zone errors (most async failures). Both are needed; either alone misses entire categories.
Profile Mode
Debug builds are slow and unrepresentative. For performance investigation:
flutter run --profileProfile mode disables asserts (like release), but keeps DevTools observability. Performance numbers in profile mode are within 10-20% of release; in debug mode they can be 5-10× slower. Never report performance issues from debug builds.
Profile mode caveats:
- Doesn't run on simulators (iOS), only physical devices and Android emulators.
- Some debugging tools are unavailable (e.g., the Inspector's repaint highlighting).
Common Failure Modes
"Setting state during build" assertion
You called setState synchronously inside a build method, or inside a child's build triggered by the parent's. The framework prevents this; the fix is to schedule the state change after the frame:
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() { ... });
});Animation never starts
AnimationController.forward() without a vsync, or the controller is disposed, or the widget is offscreen at the moment of the call. Check that:
vsync: thisis on aTickerProviderStateMixin.dispose()callscontroller.dispose().- The widget is mounted (
if (!mounted) return;before async work).
Black screen after splash
Most often a Future thrown during app startup that wasn't caught. The default FlutterError.onError prints to console — easy to miss. Wrap runApp in a runZonedGuarded to catch and surface.
"Bad state: setState called after dispose"
An async operation completed after the State was disposed. Pattern:
void _load() async {
final result = await fetch();
if (!mounted) return; // guard
setState(() => _data = result);
}The mounted check is the standard fix. The deeper fix is to cancel in-flight work in dispose.
Pre-Commit Checklist
For changes that touched debugging-relevant code:
- No leftover
print()calls — usedeveloper.log()ordebugPrint. - No leftover
debugger()breakpoints in committed code. - Disposed controllers, streams, subscriptions in
dispose(). - Async setState guarded with
if (!mounted) return;. - MethodChannel handlers catch native exceptions and call
result.error. - Native crashes route to the crash reporter with symbols uploaded.
- Performance numbers, if claimed, were captured in profile mode.