Flutter Bug Reporting Best Practices in 2026
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.0flutter pub getStep 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_symbolsUpload 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
nullpassed 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
| Error | Common Cause | Fix |
|---|---|---|
Null check operator used on null value | Using ! on a nullable | Use ?? or null-aware operators |
RangeError: Invalid value | Accessing list out of bounds | Check list length before indexing |
type 'String' is not a subtype of type 'int' | JSON parsing mismatch | Use nullable types in your model |
setState() called after dispose() | Async callback after widget unmounted | Check mounted before setState |
A build function returned null | Missing return in build method | Ensure all build paths return a Widget |
LateInitializationError | late variable accessed before init | Initialize 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.