AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Flutter Bug Reporting Best Practices in 2026

NFNourin Mahfuj Finick··8 min read

Flutter apps need the same observability as any production system. But Flutter's error model is different enough from React Native or native iOS/Android that a naive setup will miss a large percentage of your crashes. This guide covers every exception boundary in a Flutter app and how to instrument each one properly.


Understanding Flutter's Error Model


Flutter has two distinct error channels, and you need to handle both:


1. Flutter framework errors — caught by FlutterError.onError. These include widget build errors, layout overflows, and other framework-level exceptions. Flutter catches these itself and routes them here rather than crashing.


2. Dart errors outside the Flutter framework — uncaught exceptions thrown in asynchronous Dart code (timers, isolates, platform channel callbacks). These are routed through the Zone error handler, not FlutterError.onError.


If you only hook one, you're missing the other.


Step 1: Install the SDK


# pubspec.yaml
dependencies:
  bugspulse: ^1.0.0

flutter pub get

Step 2: Wrap main() in a Zone


The correct pattern wraps your entire main() function in a runZonedGuarded call to catch async Dart errors, AND sets FlutterError.onError for framework errors:


import 'package:flutter/material.dart';
import 'package:bugspulse/bugspulse.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await BugsPulse.init(
    apiKey: 'bp_your_project_key',
    environment: const String.fromEnvironment('ENV', defaultValue: 'production'),
  );

  // Catch Flutter framework errors (widget build errors, layout issues, etc.)
  FlutterError.onError = (FlutterErrorDetails details) {
    BugsPulse.captureFlutterError(details);
  };

  // Catch all other Dart async errors (timers, platform channels, isolates)
  runZonedGuarded(
    () => runApp(const MyApp()),
    (error, stackTrace) {
      BugsPulse.captureException(error, stackTrace: stackTrace);
    },
  );
}

This two-part setup is the correct way to achieve complete crash coverage in Flutter. Using only FlutterError.onError misses async errors; using only runZonedGuarded misses framework errors.


Step 3: Track Navigation Events


BugsPulse records navigation as breadcrumbs so you can reconstruct which screens the user visited before a crash. Add BugsPulseNavigatorObserver to your MaterialApp:


MaterialApp(
  navigatorObservers: [
    BugsPulseNavigatorObserver(),
  ],
  home: const HomeScreen(),
)

For apps using GoRouter:


final router = GoRouter(
  observers: [BugsPulseNavigatorObserver()],
  routes: [ /* ... */ ],
);

Every push, pop, and replace is recorded with a timestamp. When you open a crash report, you'll see the exact navigation path that led to the failure.


Step 4: Monitor Network Requests with Dio


Network errors are a leading cause of app crashes — a null response body, an unexpected status code, or a timeout that wasn't handled. If you're using Dio (the most popular Flutter HTTP client), add BugsPulse's interceptor:


import 'package:dio/dio.dart';
import 'package:bugspulse/bugspulse.dart';

final dio = Dio(BaseOptions(
  baseUrl: 'https://api.example.com',
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 30),
));

dio.interceptors.add(BugsPulseDioInterceptor(
  captureRequestBody: false,  // don't capture request bodies (privacy)
  captureResponseBody: false, // don't capture response bodies (privacy)
));

This records every request's URL, method, status code, and duration as a network event in the session timeline. When a crash happens after a 401 or a timeout, you'll see it immediately.


If you're using the built-in http package instead of Dio:


import 'package:http/http.dart' as http;
import 'package:bugspulse/bugspulse.dart';

final client = BugsPulseHttpClient(http.Client());

// Use client instead of http.get directly
final response = await client.get(Uri.parse('https://api.example.com/data'));

Step 5: Add Custom Breadcrumbs


Breadcrumbs are timestamped events that give you context about what the user was doing. Add them at important state transitions:


// When a user completes a significant action
BugsPulse.addBreadcrumb(
  message: 'User tapped checkout button',
  level: BreadcrumbLevel.info,
  data: {'cartItemCount': cart.items.length},
);

// When entering a sensitive flow
BugsPulse.addBreadcrumb(
  message: 'Entering payment flow',
  level: BreadcrumbLevel.info,
);

These show up in the session timeline alongside navigation events and network requests.


Step 6: Capture Handled Exceptions


Not every error should crash your app. For errors you catch and handle gracefully, still report them so you can track error frequency:


try {
  final data = await fetchUserProfile(userId);
  setState(() => profile = data);
} catch (e, stackTrace) {
  // Report the error but don't crash
  BugsPulse.captureException(
    e,
    stackTrace: stackTrace,
    context: {'userId': userId, 'screen': 'ProfileScreen'},
  );
  // Show a user-friendly error instead
  setState(() => showError = true);
}

Handling Platform-Specific Crashes


iOS: Memory pressure events


iOS sends memory warnings before killing your app. Record a breadcrumb so OOM kills are identifiable in crash reports:


import 'package:flutter/services.dart';

// In your main widget's initState
SystemChannels.lifecycle.setMessageHandler((msg) async {
  if (msg == AppLifecycleState.detached.toString()) {
    BugsPulse.addBreadcrumb(
      message: 'App lifecycle: detached (possible OOM)',
      level: BreadcrumbLevel.warning,
    );
  }
  return null;
});

Android: Deferred deep links and intent handling


Android deep link crashes are common and hard to reproduce. Wrap your intent handler:


@override
void initState() {
  super.initState();
  _handleInitialLink();
}

Future<void> _handleInitialLink() async {
  try {
    final uri = await getInitialUri();
    if (uri != null) _navigateToDeepLink(uri);
  } catch (e, stackTrace) {
    BugsPulse.captureException(e, stackTrace: stackTrace,
      context: {'source': 'deep_link_init'});
  }
}

Reading Flutter Crash Reports


Symbolicated Dart stack traces


Flutter's release builds compile Dart to native code. Stack traces from release builds contain obfuscated symbols unless you:


1. Build with --split-debug-info to generate a symbols file

2. Upload the symbols file to your crash reporter


flutter build apk --release \
  --obfuscate \
  --split-debug-info=./debug_symbols

Upload the debug_symbols/ directory to BugsPulse in your CI pipeline. Without this, Dart stack traces from release builds will be unreadable.


What to look for in a Flutter crash report


  • Widget in the stack — is the crash always in the same widget? Check whether it's caused by a null passed to a required parameter.
  • Network event before crash — did a Dio request return an unexpected type? A common pattern is an API returning {"error": "..."} but your model expecting a JSON object.
  • Navigation before crash — does the crash always happen on a specific screen after a specific route?
  • Flutter version — is this regression in a specific Flutter version? Check if your dependencies pin to Flutter SDK constraints.

Common Flutter Crash Patterns


ErrorCommon CauseFix
Null check operator used on null valueUsing ! on a nullableUse ?? or null-aware operators
RangeError: Invalid valueAccessing list out of boundsCheck list length before indexing
type 'String' is not a subtype of type 'int'JSON parsing mismatchUse nullable types in your model
setState() called after dispose()Async callback after widget unmountedCheck mounted before setState
A build function returned nullMissing return in build methodEnsure all build paths return a Widget
LateInitializationErrorlate variable accessed before initInitialize in initState or use late final

The setState After Dispose Pattern


The most common async crash in Flutter. An async operation completes after the widget was removed from the tree:


// Bad — will crash if widget is disposed during await
Future<void> loadData() async {
  final data = await api.fetchData();
  setState(() => items = data); // crash if already disposed
}

// Good — check mounted first
Future<void> loadData() async {
  final data = await api.fetchData();
  if (!mounted) return;
  setState(() => items = data);
}

Summary


Complete Flutter bug reporting requires four pieces working together: runZonedGuarded for async Dart errors, FlutterError.onError for framework errors, a network interceptor for HTTP context, and a navigator observer for screen context. Miss any one of them and you'll have blind spots in your crash data. Get all four right, and a crash report tells you the full story — not just where it failed, but why.