Production Error Handling in Flutter: A Complete Strategy
Flutter Development
9 min read
September 25, 2025

Production Error Handling in Flutter: A Complete Strategy

Stop showing raw stack traces to users. Learn how to build a structured error handling pipeline with sealed exceptions, severity levels, automatic logging, and user-friendly dialogs.

Muhammad Nabi Rahmani

Muhammad Nabi Rahmani

Flutter Developer passionate about creating beautiful mobile experiences

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:

SeverityWhen to UseExample
warningUser can recoverPermission denied, purchase cancelled
errorFeature is brokenDatabase read failed, API returned 500
criticalData loss riskDatabase corruption, write failure
CategorySubsystem
storageLocal DB / file failures
businessValidation / illegal state
platformNotifications, IAP, URL launch
networkHTTP / 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

  1. The Throwing Rule: Services throw AppException. Never throw generic Exception or raw strings.
  2. The Guarding Rule: Controllers wrap service calls in AsyncValue.guard.
  3. The Consolidation Rule: All exceptions live in one file. No stray exceptions in feature files.
  4. The Converter Rule: External SDK exceptions are converted to AppException at the Data layer.

Adding a New Exception (The Checklist)

  1. Add the class to app_exception.dart with severity + category
  2. Add switch cases in async_value_ui.dart (title + message)
  3. If retryable, add to isRetryable extension
  4. Implement the throw in the Service layer
  5. Ensure the Controller uses AsyncValue.guard
  6. Verify the page has ref.listen
  7. 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.

Share:

Keep Reading

More articles you might enjoy

© 2026 Mohammad Nabi RahmaniBuilt with Next.js & Tailwind