Error handling patterns in Flutter that actually work
Error handling is one of the most neglected parts of Flutter apps. Most codebases have try/catch blocks scattered across widgets, inconsistent error messages, and errors that are silently swallowed. Here is how to do it right.
The problem with ad-hoc try/catch
// Scattered, inconsistent, and hard to test:
onPressed: () async {
try {
await orderService.createOrder(items);
Navigator.pop(context);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
}
}
This shows raw exception messages to users, doesn't log errors, and is duplicated on every button.
Typed errors
Define the errors your domain can produce:
sealed class AppError {
const AppError();
}
class NetworkError extends AppError {
const NetworkError(this.message, {this.statusCode});
final String message;
final int? statusCode;
}
class ValidationError extends AppError {
const ValidationError(this.field, this.message);
final String field;
final String message;
}
class AuthError extends AppError {
const AuthError(this.reason);
final String reason; // 'session_expired' | 'unauthorized'
}
class UnknownError extends AppError {
const UnknownError(this.original);
final Object original;
}
Converting raw exceptions at the boundary
Catch exceptions at the repository/service layer and convert to typed errors:
class OrderRepository {
Future<Order> createOrder(OrderRequest request) async {
try {
final response = await _dio.post('/orders', data: request.toJson());
return Order.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
if (e.response?.statusCode == 401) throw const AuthError('session_expired');
if (e.response?.statusCode == 422) {
throw ValidationError('items', e.response?.data['message'] as String? ?? 'Invalid');
}
throw NetworkError(e.message ?? 'Network error', statusCode: e.response?.statusCode);
} catch (e) {
throw UnknownError(e);
}
}
}
Result type pattern
Use a Result type to make errors explicit in the return type:
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
class Failure<T> extends Result<T> {
const Failure(this.error);
final AppError error;
}
// Usage in repository:
Future<Result<Order>> createOrder(OrderRequest request) async {
try {
final order = await _api.createOrder(request);
return Success(order);
} on AppError catch (e) {
return Failure(e);
}
}
// Usage in UI:
final result = await orderRepo.createOrder(request);
switch (result) {
case Success(:final value):
context.go('/orders/${value.id}');
case Failure(:final error):
_handleError(error);
}
Centralised error handling in BLoC
void _handleError(AppError error, Emitter<OrderState> emit) {
switch (error) {
case AuthError():
emit(const OrderState.sessionExpired());
case NetworkError(statusCode: 503):
emit(const OrderState.serviceUnavailable());
case NetworkError(:final message):
emit(OrderState.error(message));
case ValidationError(:final field, :final message):
emit(OrderState.validationError(field: field, message: message));
case UnknownError(:final original):
_crashlytics.recordError(original, StackTrace.current);
emit(const OrderState.error('Something went wrong. Please try again.'));
}
}
Global uncaught error handler
void main() {
FlutterError.onError = (details) {
// Log to Crashlytics
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}
User-facing error messages
Never show raw exception messages to users:
String userFacingMessage(AppError error) => switch (error) {
NetworkError(statusCode: 503) => 'Service temporarily unavailable. Please try again shortly.',
NetworkError() => 'Connection failed. Check your internet and try again.',
AuthError(reason: 'session_expired') => 'Your session has expired. Please sign in again.',
ValidationError(:final message) => message,
_ => 'Something went wrong. Please try again.',
};
Common pitfalls
Swallowing errors with empty catch blocks. catch (e) {} hides bugs that are silently affecting users. Always log errors even when you recover from them gracefully.
Showing technical error messages. Stack traces and DioException messages mean nothing to users. Map every error to a human-readable string before displaying it.
Not handling errors in StreamBuilder. StreamBuilder has an error state that's easy to forget. Always handle snapshot.hasError explicitly — don't let error states silently show a blank screen.
Sign in to like, dislike, or report.