Dart Exception Handling in Production: Zones, FlutterError, and More
Dart has multiple layers of error handling, and each one catches a different category of exception. In production Flutter apps, you need all of them wired up correctly — miss one and you have blind spots in your crash data.
This is the complete guide to every exception boundary in a Flutter app and what each one catches.
The Four Exception Boundaries
1. FlutterError.onError
Catches exceptions thrown inside the Flutter framework — widget build errors, layout overflows, framework assertions, rendering errors.
FlutterError.onError = (FlutterErrorDetails details) {
// In release mode: report and continue
// In debug mode: also show the red screen
if (kReleaseMode) {
BugsPulse.captureFlutterError(details);
} else {
FlutterError.presentError(details); // shows red error screen in debug
}
};What it catches:
- Widget build exceptions (
build()throws) - RenderObject exceptions
- Layout overflow errors (the yellow-striped boxes in debug mode)
InheritedWidgetandProviderexceptions
What it does NOT catch:
- Async Dart errors outside the framework
- Platform channel exceptions
- Isolate errors
2. runZonedGuarded
Catches all uncaught async Dart errors — exceptions thrown in Future callbacks, async/await code, and Timer callbacks that occur within the zone.
runZonedGuarded(
() => runApp(const MyApp()),
(Object error, StackTrace stackTrace) {
BugsPulse.captureException(error, stackTrace: stackTrace);
},
);What it catches:
- Uncaught
Futureerrors - Uncaught
async/awaitexceptions Timercallback exceptions- Stream errors that aren't handled by a
onErrorcallback
What it does NOT catch:
- Errors in a different zone (e.g.,
compute()runs in a separate isolate) - FlutterError framework errors (those go to
FlutterError.onError) - Errors explicitly caught by your own try/catch
3. PlatformDispatcher.instance.onError (Flutter 3.3+)
Flutter 3.3 introduced PlatformDispatcher.onError as an additional top-level error handler. It catches errors that escape all other zones:
PlatformDispatcher.instance.onError = (error, stack) {
BugsPulse.captureException(error, stackTrace: stack);
return true; // return true to prevent the default crash
};This is a safety net — if runZonedGuarded doesn't catch something, PlatformDispatcher.onError usually will.
4. Isolate.current.addErrorListener
Errors in other isolates (spawned via Isolate.spawn or compute()) don't propagate to the main isolate automatically. You must add an error listener:
final receivePort = ReceivePort();
final errorPort = ReceivePort();
final isolate = await Isolate.spawn(
heavyComputation,
receivePort.sendPort,
onError: errorPort.sendPort,
);
errorPort.listen((error) {
if (error is List) {
final errorMessage = error[0] as String;
final stackTrace = error[1] as String;
BugsPulse.captureException(
Exception(errorMessage),
context: {'isolate_stack': stackTrace},
);
}
});The Complete Production Setup
Combine all four in your main.dart:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await BugsPulse.init(apiKey: 'bp_your_key');
// Layer 1: Flutter framework errors
FlutterError.onError = (details) {
BugsPulse.captureFlutterError(details);
if (!kReleaseMode) FlutterError.presentError(details);
};
// Layer 2: Top-level platform errors (Flutter 3.3+)
PlatformDispatcher.instance.onError = (error, stack) {
BugsPulse.captureException(error, stackTrace: stack);
return true;
};
// Layer 3: Async Dart errors
runZonedGuarded(
() => runApp(const MyApp()),
(error, stack) => BugsPulse.captureException(error, stackTrace: stack),
);
}Dart Error Types You'll See in Production
FormatException
Thrown when parsing fails — JSON, dates, URLs. Almost always caused by an unexpected API response:
// WRONG
final data = jsonDecode(response.body) as Map<String, dynamic>;
// CORRECT — always guard JSON parsing
try {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return MyModel.fromJson(data);
} on FormatException catch (e, stack) {
BugsPulse.captureException(e, stackTrace: stack,
context: {'response_body_preview': response.body.substring(0, 200)});
rethrow;
}TypeError (type cast failure)
// Crashes if API returns null or wrong type
final count = data['count'] as int; // crashes if 'count' is null or a String
// CORRECT
final count = (data['count'] as num?)?.toInt() ?? 0;StateError
// Calling .first on an empty iterable
final first = items.first; // throws StateError: No element
// CORRECT
final first = items.firstOrNull;RangeError
// Accessing list beyond its length
final item = items[index]; // throws if index >= items.length
// CORRECT
final item = index < items.length ? items[index] : null;Zone Gotchas
compute() runs outside your zone — compute() spawns a fresh isolate with no zone. Errors inside compute() callbacks propagate back as RemoteError — handle them at the call site:
try {
final result = await compute(parseData, rawData);
} catch (e, stack) {
BugsPulse.captureException(e, stackTrace: stack);
}StreamController errors — A stream that emits an error without a listener on the onError handler will propagate to the zone:
// Add error handling to all stream subscriptions
stream.listen(
(data) => handleData(data),
onError: (e, stack) => BugsPulse.captureException(e, stackTrace: stack),
cancelOnError: false,
);With all four layers in place, no Dart or Flutter exception escapes uncaptured in production.