Skip to content
ExponaBLOG
FLUTTER · DAY 091 / 100

Show friendly errors: Flutter best practices explained

Users should never see a red error screen.

Expona Admin11 min read

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:

dart
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:

dart
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:

dart
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:

dart
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.

dart
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.

dart
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:

dart
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 AppException hierarchy, then transform those to user-friendly ErrorPresentation objects 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles