The Cost of Red Error Screens
Users panic when they see a red error screen. They don't know what went wrong, whether it's their fault, or what to do next. In production Flutter apps across e-commerce, travel, and ride-hailing platforms, we've learned that show friendly errors: Flutter best practices explained isn't optional—it's the difference between a retained user and an uninstall. When your app crashes with a stack trace or displays a generic "Something went wrong," you've failed the user experience test. The good news is that handling errors gracefully is straightforward once you establish a system.
In this article, we'll walk through a production-tested approach to error handling that keeps your app stable and your users informed. We'll cover mapping exceptions to user-friendly messages, offering retry actions, logging technical details separately, and testing offline and timeout cases.
Map Exceptions to User-Friendly Messages
Your backend and network layer throw dozens of exception types. SocketException, TimeoutException, FormatException, custom domain errors—each means something different to the code but nothing intelligible to users. The first step is creating a clear mapping layer that transforms technical errors into human-readable guidance.
Start by defining an exception hierarchy that separates concerns:
abstract class AppException implements Exception {
final String message;
final String? code;
final dynamic originalError;
AppException({
required this.message,
this.code,
this.originalError,
});
}
class NetworkException extends AppException {
NetworkException({String? message, String? code, dynamic originalError})
: super(
message: message ?? 'No internet connection',
code: code ?? 'network_error',
originalError: originalError,
);
}
class TimeoutException extends AppException {
TimeoutException({String? message, String? code, dynamic originalError})
: super(
message: message ?? 'Request timed out. Please try again.',
code: code ?? 'timeout_error',
originalError: originalError,
);
}
class ValidationException extends AppException {
final Map? fieldErrors;
ValidationException({
required String message,
this.fieldErrors,
String? code,
dynamic originalError,
}) : super(
message: message,
code: code ?? 'validation_error',
originalError: originalError,
);
}
class ServerException extends AppException {
ServerException({String? message, String? code, dynamic originalError})
: super(
message: message ?? 'Server error. Please try again later.',
code: code ?? 'server_error',
originalError: originalError,
);
}
class UnauthorizedException extends AppException {
UnauthorizedException({String? message, String? code, dynamic originalError})
: super(
message: message ?? 'Session expired. Please log in again.',
code: code ?? 'unauthorized',
originalError: originalError,
);
}Now create a converter that catches raw exceptions and transforms them:
AppException convertException(dynamic error, StackTrace stackTrace) {
if (error is AppException) return error;
if (error is SocketException) {
return NetworkException(originalError: error);
}
if (error is TimeoutException) {
return TimeoutException(originalError: error);
}
if (error is DioException) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return TimeoutException(originalError: error);
}
if (error.response?.statusCode == 401) {
return UnauthorizedException(
message: error.response?.data['message'] ?? 'Unauthorized',
originalError: error,
);
}
if (error.response?.statusCode == 422) {
return ValidationException(
message: 'Please check your input',
fieldErrors: _parseValidationErrors(error.response?.data),
originalError: error,
);
}
if (error.response?.statusCode != null &&
error.response!.statusCode! >= 500) {
return ServerException(
message: error.response?.data['message'] ?? 'Server error',
originalError: error,
);
}
return NetworkException(originalError: error);
}
return AppException(
message: 'An unexpected error occurred',
code: 'unknown_error',
originalError: error,
);
}
Map _parseValidationErrors(dynamic data) {
if (data is Map && data.containsKey('errors')) {
return Map.from(data['errors'] as Map);
}
return {};
}This approach ensures that whether a user has no internet or the server is down, they get a specific, actionable message—never a red screen with a stack trace.
Offer Retry Actions
Once you've mapped the exception, give users a clear path forward. A transient error (network glitch, timeout) should offer a retry button. A permanent error (validation failure) should offer guidance on what to fix.
Define an action model that pairs with each error:
class ErrorAction {
final String label;
final Future Function() onPressed;
ErrorAction({
required this.label,
required this.onPressed,
});
}
class ErrorPresentation {
final String title;
final String message;
final String? code;
final List actions;
final bool isDismissible;
ErrorPresentation({
required this.title,
required this.message,
this.code,
this.actions = const [],
this.isDismissible = true,
});
factory ErrorPresentation.fromException(
AppException exception,
VoidCallback onRetry,
) {
switch (exception.runtimeType) {
case NetworkException:
return ErrorPresentation(
title: 'No Connection',
message: exception.message,
code: exception.code,
actions: [
ErrorAction(
label: 'Retry',
onPressed: () async => onRetry(),
),
],
);
case TimeoutException:
return ErrorPresentation(
title: 'Taking Too Long',
message: exception.message,
code: exception.code,
actions: [
ErrorAction(
label: 'Try Again',
onPressed: () async => onRetry(),
),
],
);
case ValidationException ve:
return ErrorPresentation(
title: 'Please Check Your Input',
message: ve.message,
code: ve.code,
actions: [],
isDismissible: true,
);
case UnauthorizedException:
return ErrorPresentation(
title: 'Session Expired',
message: exception.message,
code: exception.code,
actions: [
ErrorAction(
label: 'Log In Again',
onPressed: () async {
// Navigate to login
},
),
],
);
case ServerException:
return ErrorPresentation(
title: 'Server Issue',
message: exception.message,
code: exception.code,
actions: [
ErrorAction(
label: 'Retry',
onPressed: () async => onRetry(),
),
],
);
default:
return ErrorPresentation(
title: 'Something Went Wrong',
message: 'An unexpected error occurred. Please try again.',
code: exception.code,
actions: [
ErrorAction(
label: 'Retry',
onPressed: () async => onRetry(),
),
],
);
}
}
}In your UI, show this as a modal or snackbar, never a crash:
void showErrorDialog(
BuildContext context,
ErrorPresentation error,
) {
showDialog(
context: context,
barrierDismissible: error.isDismissible,
builder: (context) => AlertDialog(
title: Text(error.title),
content: Text(error.message),
actions: [
...error.actions.map(
(action) => TextButton(
onPressed: () {
Navigator.pop(context);
action.onPressed();
},
child: Text(action.label),
),
),
if (error.isDismissible)
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Dismiss'),
),
],
),
);
}Log Technical Details Separately
Don't expose stack traces to users, but do capture them for debugging. Maintain a separate logging layer that records the original exception, stack trace, and context.
class ErrorLogger {
final LogService _logService;
ErrorLogger(this._logService);
Future log(
AppException exception,
StackTrace stackTrace, {
Map? context,
}) async {
final logEntry = {
'timestamp': DateTime.now().toIso8601String(),
'error_code': exception.code,
'error_type': exception.runtimeType.toString(),
'message': exception.message,
'original_error': exception.originalError.toString(),
'stack_trace': stackTrace.toString(),
'context': context ?? {},
};
// Send to Crashlytics, Sentry, or your backend
await _logService.recordError(logEntry);
// Also log locally for offline debugging
await _logService.writeLocal(logEntry);
}
}
// Usage in your service layer
Future fetchUser(String id) async {
try {
final response = await dio.get('/users/$id');
return User.fromJson(response.data);
} catch (error, stackTrace) {
final appException = convertException(error, stackTrace);
await errorLogger.log(
appException,
stackTrace,
context: {'endpoint': '/users/$id', 'userId': id},
);
rethrow;
}
}This way, your support team and developers can investigate real issues while users see clean, helpful messages.
Test Offline and Timeout Cases
Error handling is useless if you don't test it. Create explicit test scenarios for network failures, timeouts, and server errors.
void main() {
group('Error handling', () {
late MockDio mockDio;
late UserRepository userRepository;
setUp(() {
mockDio = MockDio();
userRepository = UserRepository(mockDio);
});
test('NetworkException on socket error', () async {
mockDio.throwsException(SocketException('Network error'));
expect(
() => userRepository.fetchUser('123'),
throwsA(isA()),
);
});
test('TimeoutException on timeout', () async {
mockDio.throwsException(
DioException(
requestOptions: RequestOptions(path: '/users/123'),
type: DioExceptionType.connectionTimeout,
),
);
expect(
() => userRepository.fetchUser('123'),
throwsA(isA()),
);
});
test('ValidationException on 422 response', () async {
mockDio.throwsException(
DioException(
requestOptions: RequestOptions(path: '/users'),
response: Response(
requestOptions: RequestOptions(path: '/users'),
statusCode: 422,
data: {
'errors': {
'email': 'Email already exists',
'name': 'Name is required',
},
},
),
),
);
expect(
() => userRepository.createUser('user'),
throwsA(isA()),
);
});
test('ErrorPresentation shows retry for network errors', () async {
final networkError = NetworkException();
final presentation = ErrorPresentation.fromException(
networkError,
() {},
);
expect(presentation.title, 'No Connection');
expect(presentation.actions.length, 1);
expect(presentation.actions.first.label, 'Retry');
});
test('ErrorPresentation shows no retry for validation errors', () async {
final validationError = ValidationException(
message: 'Check your input',
fieldErrors: {'email': 'Invalid format'},
);
final presentation = ErrorPresentation.fromException(
validationError,
() {},
);
expect(presentation.title, 'Please Check Your Input');
expect(presentation.actions.isEmpty, true);
});
});
}Also test the UI layer to ensure dialogs appear correctly and actions trigger:
void main() {
testWidgets('Error dialog appears and retry works', (tester) async {
final errorScaffold = MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () {
final error = ErrorPresentation(
title: 'Connection Failed',
message: 'Please check your internet.',
actions: [
ErrorAction(
label: 'Retry',
onPressed: () {},
),
],
);
showErrorDialog(context, error);
},
child: const Text('Show Error'),
),
),
),
);
await tester.pumpWidget(errorScaffold);
await tester.tap(find.text('Show Error'));
await tester.pumpAndSettle();
expect(find.text('Connection Failed'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
expect(find.byType(AlertDialog), findsOneWidget);
});
}Common Mistakes
We've seen teams make these errors repeatedly:
- Showing raw exception messages to users. "FormatException: Unexpected end of input" means nothing. Always map to a friendly message.
- No retry mechanism for transient errors. If a request times out, users should be able to retry, not navigate backward.
- Losing technical context. Log the original exception and stack trace even if the user sees a friendly message. You need that for debugging.
- Not testing offline. Test your app with airplane mode on, network disabled, or using a mock that throws exceptions at random intervals.
- The same action for all errors. A timeout and a validation error require different user responses. Tailor your actions to the error type.
- Silent failures. Errors that disappear without user feedback erode trust. Always inform the user that something happened.
Key Takeaways
- Map all exception types to a custom
AppExceptionhierarchy, then transform those to user-friendlyErrorPresentationobjects with clear titles and messages. - Pair each error with contextual actions: retry for transient failures, dismiss for validation errors, log in again for auth failures.
- Log the original exception, stack trace, and context to your backend or crash reporting service so developers can investigate production issues.
- Test offline and timeout scenarios explicitly with mocks that throw the exceptions you expect in the field.
- Never show a red error screen or stack trace to users. A friendly dialog with next steps turns an error into a recoverable moment.






Comments
Loading comments…