Flutter Crash Reporting: Full Setup with FlutterError & Dio (2026)
Flutter's error model is different from most mobile frameworks — and the difference matters for crash reporting. Flutter has two separate error channels, and most crash reporting setups only hook into one. This guide covers both, plus Dio network monitoring, navigator tracking, and Dart symbolication — everything you need for production-grade Flutter observability.
Flutter's Two Error Channels
Flutter separates errors into two categories:
FlutterError errors — exceptions thrown inside the Flutter framework: widget build errors, layout overflow, framework assertion failures. Flutter catches these internally and routes them to FlutterError.onError. If this handler isn't set, Flutter logs them to the console in debug mode and silently swallows them in release mode.
Dart Zone errors — uncaught exceptions thrown in asynchronous Dart code that runs outside the Flutter framework: Future callbacks, Timer callbacks, platform channel callbacks, isolate errors. These are routed to the Zone's error handler, not to FlutterError.onError.
If you only hook FlutterError.onError, you miss all async Dart errors. If you only use runZonedGuarded, you miss framework errors. You need both.
Complete main() Setup
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:bugspulse/bugspulse.dart';
Future<void> main() async {
// Must be called before any Flutter framework usage
WidgetsFlutterBinding.ensureInitialized();
// Initialize BugsPulse
await BugsPulse.init(
apiKey: 'bp_your_project_key',
environment: kReleaseMode ? 'production' : 'development',
captureNetworkRequests: true,
sessionReplay: true,
);
// Channel 1: Flutter framework errors
FlutterError.onError = (FlutterErrorDetails details) {
// In release mode, forward to BugsPulse
// In debug mode, also print to console
if (kReleaseMode) {
BugsPulse.captureFlutterError(details);
} else {
FlutterError.presentError(details);
}
};
// Channel 2: Async Dart errors
runZonedGuarded(
() => runApp(const MyApp()),
(Object error, StackTrace stackTrace) {
BugsPulse.captureException(error, stackTrace: stackTrace);
},
);
}This two-part setup is the only way to achieve complete crash coverage in Flutter.
Navigation Tracking
Add BugsPulseNavigatorObserver to capture every route change as a breadcrumb:
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My App',
navigatorObservers: [
BugsPulseNavigatorObserver(),
],
home: const HomeScreen(),
);
}
}For GoRouter:
final _router = GoRouter(
observers: [BugsPulseNavigatorObserver()],
routes: [
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute(path: '/profile', builder: (context, state) => const ProfileScreen()),
// ...
],
);
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
);
}
}Every push, pop, and replace is recorded with a timestamp. When you open a crash report, you'll see the exact sequence of screens the user navigated before the crash.
Dio Network Monitoring
Dio is the most popular Flutter HTTP client. BugsPulse includes a Dio interceptor that records every request as a network event in the session timeline:
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),
),
);
// Add BugsPulse interceptor
dio.interceptors.add(
BugsPulseDioInterceptor(
captureRequestBody: false, // never capture bodies (privacy)
captureResponseBody: false, // never capture response bodies
captureHeaders: false, // avoid capturing auth tokens
),
);This records: URL, method, status code, response time. It does not capture request/response bodies by default.
For apps using the built-in http package:
import 'package:http/http.dart' as http;
import 'package:bugspulse/bugspulse.dart';
// Wrap the default http.Client
final client = BugsPulseHttpClient(http.Client());
// Use client.get() instead of http.get()
final response = await client.get(
Uri.parse('https://api.example.com/data'),
);Custom Breadcrumbs and Context
Add breadcrumbs at meaningful points in your app flow:
// When a user taps a key action
await BugsPulse.addBreadcrumb(
message: 'User tapped Place Order',
level: BreadcrumbLevel.info,
data: {
'orderId': order.id,
'itemCount': order.items.length,
'totalAmount': order.total,
},
);
// When entering a sensitive flow
await BugsPulse.addBreadcrumb(
message: 'Entered payment flow',
level: BreadcrumbLevel.info,
);Set user context after authentication:
await BugsPulse.setUser(
id: user.id, // numeric or UUID — not email
data: {
'planId': user.planId,
'accountAge': user.daysSinceSignup,
},
);Handling the setState After Dispose Pattern
The most common Flutter crash pattern. An async operation completes after the widget was removed from the widget tree:
// WRONG — crashes if widget is disposed during the await
Future<void> _loadProfile() async {
final profile = await api.fetchProfile(userId);
setState(() => _profile = profile); // throws if disposed
}
// CORRECT — check mounted before setState
Future<void> _loadProfile() async {
final profile = await api.fetchProfile(userId);
if (!mounted) return;
setState(() => _profile = profile);For StatefulWidgets with complex async flows, consider using the flutter_hooks package's useEffect which handles cleanup automatically.
Capturing Handled Exceptions
For errors you catch and recover from, still report them to track their frequency:
Future<void> _syncData() async {
try {
await dataService.sync();
} on TimeoutException catch (e, stackTrace) {
// Handle gracefully but still report
await BugsPulse.captureException(
e,
stackTrace: stackTrace,
context: {'operation': 'data_sync', 'screen': 'SyncScreen'},
);
setState(() => _syncError = 'Sync timed out. Pull to retry.');
}
}Dart Symbolication for Release Builds
Flutter's release builds compile Dart to native code using AOT compilation. Stack traces from release builds contain obfuscated symbols unless you generate and upload a symbols file.
Build with obfuscation + split debug info
# Android
flutter build apk --release \
--obfuscate \
--split-debug-info=build/debug-info/android
# iOS
flutter build ipa --release \
--obfuscate \
--split-debug-info=build/debug-info/iosThis generates a debug-info/ directory containing .so (Android) or .dSYM-equivalent (iOS) files.
Upload to BugsPulse
npx bugspulse-cli upload-dart-symbols \
--api-key bp_your_project_key \
--version 1.2.3 \
--platform android \
--symbols-dir build/debug-info/androidAdd this to your CI/CD pipeline after every release build. Without it, Flutter release crash traces are unreadable.
Verifying symbolication works
After uploading symbols, trigger a test crash in a release build:
// In a debug-only button or test screen
throw Exception('Test crash for symbolication verification');Open the crash in your BugsPulse dashboard. If symbolication is working, you'll see function names and file paths instead of hex addresses.
Production Triage Workflow for Flutter
Weekly review: Sort crashes by unique users affected. Prioritize anything affecting more than 10 users.
Pattern recognition: Open multiple sessions for the same crash fingerprint. Common Flutter patterns:
Null check operator used on a null value→ almost always a!on a nullable that arrived as null from an APIsetState() called after dispose()→ async operation completed after navigation awayRangeError: index out of range→ list modified while being iterated, or index from API exceeding local list size
Platform split: Check if the crash is iOS-only, Android-only, or cross-platform. iOS-only crashes often indicate memory pressure issues or App Store review device constraints. Android-only crashes often indicate OEM customization or older API level behavior.
Flutter version correlation: Check if the crash correlates with a Flutter version upgrade. The Flutter framework changes APIs between minor versions, and crash reporters will show the app version in crash data.
Common Flutter Crash Patterns and Fixes
| Error | Common Cause | Fix |
|---|---|---|
Null check operator used on null value | ! on nullable | Use ?? or null-safe operators |
setState() called after dispose() | Async after navigation | Check if (!mounted) return |
type 'Null' is not a subtype of type 'String' | JSON field missing | Make model field nullable |
RangeError: index out of range | Off-by-one index | Validate bounds before access |
A build function returned null | Missing return | All build paths must return Widget |
LateInitializationError | late field before init | Initialize in initState or make nullable |
ConcurrentModificationError | Modifying list while iterating | Iterate a copy: List.from(items) |
Monitoring Memory Warnings
iOS sends memory warnings before OOM-killing your app. Record a breadcrumb to identify OOM kills in crash data:
@override
void initState() {
super.initState();
SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage);
}
Future<String?> _handleLifecycleMessage(String? message) async {
if (message == 'AppLifecycleState.detached') {
await BugsPulse.addBreadcrumb(
message: 'App detached (possible OOM)',
level: BreadcrumbLevel.warning,
);
}
return null;
}Summary
Complete Flutter crash reporting requires three pieces working together: FlutterError.onError for framework errors, runZonedGuarded for async Dart errors, and symbol upload for readable stack traces. Add navigation tracking and network monitoring and your crash reports tell the full story of every failure — not just the line number, but the complete journey that led there.