Production Error Handling in Flutter: A Complete Strategy
Your app will crash. APIs will fail. Databases will throw errors. The question isn't whether errors happen - it's whether your users see a helpful message or a terrifying stack trace. Let's build an error handling system that makes both developers and users happy.
The Philosophy: Safe-Fail
Instead of trying to prevent all errors (impossible), we design our app to fail gracefully. Every error is caught, logged through a structured pipeline, and presented to the user as a helpful, non-technical message.
Step 1: A Sealed Exception Hierarchy
Every error in your app should be an AppException. No generic Exception, no thrown strings, no raw errors reaching the UI.
sealed class AppException implements Exception {
final String code;
final String message;
final String? details;
AppException(this.code, this.message, {this.details});
ErrorSeverity get severity;
ErrorCategory get category;
}
Why Sealed?
Dart's sealed keyword means the compiler forces you to handle every exception type. Add a new exception? Every switch statement that handles AppException will show a compile error until you add the new case. No exception can slip through unhandled.
Severity and Category
Every exception carries metadata that tells the logging pipeline how important it is:
| Severity | When to Use | Example |
|---|---|---|
warning | User can recover | Permission denied, purchase cancelled |
error | Feature is broken | Database read failed, API returned 500 |
critical | Data loss risk | Database corruption, write failure |
| Category | Subsystem |
|---|---|
storage | Local DB / file failures |
business | Validation / illegal state |
platform | Notifications, IAP, URL launch |
network | HTTP / connectivity |
Concrete Exceptions
class DatabaseException extends AppException {
DatabaseException(String msg, {String? details})
: super('database-error', msg, details: details);
@override
ErrorSeverity get severity => ErrorSeverity.critical;
@override
ErrorCategory get category => ErrorCategory.storage;
}
class ValidationException extends AppException {
ValidationException(String msg)
: super('validation-error', msg);
@override
ErrorSeverity get severity => ErrorSeverity.warning;
@override
ErrorCategory get category => ErrorCategory.business;
}
class NoConnectionException extends AppException {
NoConnectionException({String? details})
: super('no-connection', 'No internet connection', details: details);
@override
ErrorSeverity get severity => ErrorSeverity.warning;
@override
ErrorCategory get category => ErrorCategory.network;
}
All exceptions live in one file: core/errors/app_exception.dart. Never scatter exception classes across feature files.
Step 2: The Logging Pipeline
Your ErrorLogger uses dart:developer log() with severity-based log levels:
class ErrorLogger {
final AnalyticsFacade _analytics;
void logAppException(AppException exception, [StackTrace? stackTrace]) {
final logLevel = switch (exception.severity) {
ErrorSeverity.warning => 900,
ErrorSeverity.error => 1000,
ErrorSeverity.critical => 1200,
};
developer.log(
'[${exception.severity.name.toUpperCase()}] '
'${exception.category.name} | '
'${exception.code}: ${exception.message}',
name: 'Error',
level: logLevel,
);
// Fan out to analytics (Firebase, Mixpanel, local DB)
_analytics.trackError(
code: exception.code,
message: exception.message,
severity: exception.severity,
);
}
}
Automatic Catching with ProviderObserver
Riverpod's ProviderObserver catches every AsyncError state transition automatically:
class AsyncErrorLogger extends ProviderObserver {
@override
void didUpdateProvider(
ProviderBase provider, Object? previousValue, Object? newValue,
ProviderContainer container,
) {
if (newValue is AsyncError) {
final error = newValue.error;
if (error is AppException) {
errorLogger.logAppException(error, newValue.stackTrace);
} else {
errorLogger.logError(error, newValue.stackTrace);
}
}
}
}
// Register in main.dart:
ProviderScope(observers: [AsyncErrorLogger()], child: MyApp())
Every controller that fails now gets automatically logged. Zero extra code in each controller.
Step 3: User-Friendly Error Dialogs
The UI layer converts sealed exceptions into human-readable messages using exhaustive switches:
extension AsyncValueUI on AsyncValue<dynamic> {
void showAlertDialogOnError(BuildContext context) {
if (isLoading || !hasError) return;
// Some errors are silently dismissed
if (error is PurchaseCancelledException) return;
final title = switch (error as AppException) {
DatabaseException() => 'Storage Error',
ValidationException() => 'Invalid Input',
NetworkException() => 'Connection Error',
NoConnectionException() => 'You\'re Offline',
// ... all cases (compiler-enforced)
};
showAlertDialog(context: context, title: title, content: message);
}
}
Every Page Needs ref.listen
Every page with async controllers must listen for errors:
class TaskScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(toggleTaskControllerProvider, (_, state) {
state.showAlertDialogOnError(context);
});
// ... rest of build
}
}
Step 4: Convert External Errors at the Boundary
External SDKs throw their own exceptions (Dio, platform plugins). Convert them to AppException at the Data layer boundary:
AppException appExceptionFromDio(DioException e) {
return switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout => ApiTimeoutException(details: e.message),
DioExceptionType.connectionError => NoConnectionException(details: e.message),
_ => NetworkException(
e.response?.statusMessage ?? 'Request failed',
details: 'HTTP ${e.response?.statusCode}: ${e.message}',
),
};
}
// Usage in repositories:
try {
final response = await dio.get('/endpoint');
} on DioException catch (e) {
throw appExceptionFromDio(e);
}
Step 5: Global Safety Nets
Catch anything that slips through:
void registerErrorHandlers(ErrorLogger errorLogger) {
// Flutter framework errors
FlutterError.onError = (details) {
FlutterError.presentError(details);
errorLogger.logError(details.exception, details.stack);
};
// Platform/async errors
PlatformDispatcher.instance.onError = (error, stack) {
errorLogger.logError(error, stack);
return true;
};
}
The Four Rules
- The Throwing Rule: Services throw
AppException. Never throw genericExceptionor raw strings. - The Guarding Rule: Controllers wrap service calls in
AsyncValue.guard. - The Consolidation Rule: All exceptions live in one file. No stray exceptions in feature files.
- The Converter Rule: External SDK exceptions are converted to
AppExceptionat the Data layer.
Adding a New Exception (The Checklist)
- Add the class to
app_exception.dartwith severity + category - Add switch cases in
async_value_ui.dart(title + message) - If retryable, add to
isRetryableextension - Implement the
throwin the Service layer - Ensure the Controller uses
AsyncValue.guard - Verify the page has
ref.listen - Run
flutter analyze- Dart enforces exhaustive switches
The compiler is your safety net. Miss a case? Compile error. Forget to handle a new exception in the UI? Compile error. This is the power of sealed classes.
What This Gets You
- Users see friendly messages, not stack traces
- Developers see structured logs with severity, category, and context
- Analytics track every error automatically
- New exceptions are impossible to forget thanks to exhaustive switches
- Testing is straightforward because error behavior is deterministic
Error handling isn't glamorous. But it's the difference between an app that feels amateur and one that feels professional. When things go wrong - and they will - your app should handle it with grace.



