Steven's Knowledge

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

ToolWhat it's for
flutter run --verboseBuild / install / hot-reload diagnostics
Flutter DevToolsInspector, performance, memory, network, logging, CPU profiling
dart:developer log() and debugger()Structured logs and programmatic breakpoints
debugPrintThrottled, large-payload-safe print
Observatory / VM ServiceUnderlying 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 devtools

DevTools 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:

  1. UI thread budget exceeded. Build/layout/paint took too long — Dart work in build or an expensive widget tree.
  2. Raster thread budget exceeded. GPU work too heavy — too many layers, large images, expensive shaders.
  3. 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:

  1. Take a snapshot. Note baseline.
  2. Perform the suspect operation (open and close a screen many times).
  3. Take another snapshot.
  4. Diff. Objects that should have been collected but weren't are leaks.

The common Flutter leaks:

  • A StreamSubscription, AnimationController, or TextEditingController not disposed in dispose().
  • A static reference holding onto a State object.
  • 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 final or const variables).
  • 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:

  1. Hot restart.
  2. If still wrong, flutter clean && flutter run.
  3. 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 → platform Int / NSNumber. Dart MapMap<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 --profile

Profile 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: this is on a TickerProviderStateMixin.
  • dispose() calls controller.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 — use developer.log() or debugPrint.
  • 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.

On this page