Skip to content
ExponaBLOG
FLUTTER · DAY 090 / 100

Global error handling in Flutter: lessons from production

Catch errors before users see a blank screen.

Expona Admin6 min read

In production, a single uncaught exception can turn your app into a useless brick—users see a blank screen or a cryptic error message, and they leave. Global error handling in Flutter isn't optional; it's the difference between a professional app and one that feels broken. Over my years building production platforms, I've learned that catching errors before users see them is one of the highest-impact practices you can implement.

Why global error handling matters

You can't catch every error in a try-catch block, and relying on local error boundaries leads to inconsistency. Framework errors (widget build failures), async errors (uncaught futures), and native platform crashes all bypass your local handlers. Without a global strategy, you're flying blind in production. When I was building a ride-hailing platform, we discovered that 15% of crashes weren't even visible in logs because they crashed silently in the Dart runtime.

Global error handling gives you three things: visibility into what's breaking, control over what users see, and a channel to report errors back to your team in real time.

FlutterError.onError — Catches framework errors

The Flutter framework itself throws errors when widgets fail to build, layouts overflow, or rendering code breaks. These errors land in FlutterError.onError, which is your first line of defense.

dart
void main() {
  FlutterError.onError = (FlutterErrorDetails details) {
    // Log the error
    print('FlutterError: ${details.exception}');
    print('Stack: ${details.stack}');
    
    // Send to crash reporting
    CrashReporter.recordFlutterError(details);
  };
  
  runApp(const MyApp());
}

In a real scenario, imagine a user triggers a state update that causes a widget rebuild, but the new state leaves a ListView in an invalid state. Without a handler, the app crashes silently. With FlutterError.onError, you capture the exact error and stack trace, log it with context, and notify your backend.

One common pattern is to separate development and production behavior:

dart
void main() {
  FlutterError.onError = (FlutterErrorDetails details) {
    if (kDebugMode) {
      FlutterError.presentError(details);
    } else {
      CrashReporter.recordFlutterError(details);
    }
  };
  
  runApp(const MyApp());
}

This lets you see errors during development (the default red screen is helpful) but silently report them in production, so the user experience isn't disrupted by the error UI.

PlatformDispatcher.onError — Catches uncaught async errors

Dart's async model means errors can escape try-catch blocks. If a Future fails and nobody awaits it with a .catchError() or try-catch, it vanishes into the ether—or lands in PlatformDispatcher.onError.

dart
void main() {
  PlatformDispatcher.instance.onError = (error, stack) {
    // Catch uncaught async errors
    print('Uncaught error: $error');
    print('Stack: $stack');
    
    CrashReporter.recordError(error, stack);
    return true; // Return true to prevent default behavior
  };
  
  FlutterError.onError = (FlutterErrorDetails details) {
    CrashReporter.recordFlutterError(details);
  };
  
  runApp(const MyApp());
}

A practical example: your app calls an API in the background without awaiting it (maybe you fire a tracking event and don't care about the result). If the network fails, the exception propagates to PlatformDispatcher.onError. Without a handler, it's lost. With one, you log it and move on.

The key difference is that FlutterError.onError handles framework-specific errors, while PlatformDispatcher.onError catches everything else—database errors, parsing failures, unhandled rejections from plugins, and more.

ErrorWidget.builder — Friendly fallback UI for build errors

Even with global handlers, sometimes a widget fails to build. By default, Flutter shows a red screen with an error message—not user-friendly. ErrorWidget.builder lets you replace it with a custom fallback UI.

dart
void main() {
  ErrorWidget.builder = (FlutterErrorDetails details) {
    return Scaffold(
      appBar: AppBar(title: const Text('Oops')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'Something went wrong',
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () {
                // Navigate home or restart
                Navigator.of(details.context).pushNamedAndRemoveUntil(
                  '/',
                  (route) => false,
                );
              },
              child: const Text('Go Home'),
            ),
          ],
        ),
      ),
    );
  };
  
  FlutterError.onError = (FlutterErrorDetails details) {
    CrashReporter.recordFlutterError(details);
  };
  
  runApp(const MyApp());
}

In production, this is critical. Users see a friendly "Something went wrong" message instead of a red screen, and they have a path forward (a button to retry or go home). Meanwhile, the error is logged server-side so your team can fix it.

On a travel booking app I worked on, a null pointer in a complex widget tree could crash the entire screen. With a custom ErrorWidget, users saw a recovery UI and could continue browsing. The error was still reported, but the UX didn't tank.

Report everything — Send errors to your crash reporting service

Logging errors locally is useless if nobody reads the logs. In production, you need a crash reporting service that aggregates errors, groups similar ones, and alerts your team. Popular options include Firebase Crashlytics, Sentry, and Bugsnag.

Here's how to wire it up with Crashlytics:

dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  
  FlutterError.onError = (FlutterErrorDetails details) {
    FirebaseCrashlytics.instance.recordFlutterFatalError(details);
  };
  
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    return true;
  };
  
  runApp(const MyApp());
}

Now every error—framework, async, or otherwise—flows to Firebase. You can set up alerts, track error trends, and prioritize fixes based on severity and frequency.

For a CRM platform I built, we set up Crashlytics to group errors by user, device, and app version. When a crash spiked after a deploy, we caught it within minutes because an alert fired. Without that reporting layer, we might have only discovered it from angry support tickets.

Beyond logging, attach metadata:

dart
PlatformDispatcher.instance.onError = (error, stack) {
  FirebaseCrashlytics.instance.setCustomKey('user_id', currentUser.id);
  FirebaseCrashlytics.instance.setCustomKey('feature', 'checkout');
  FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
  return true;
};

This context helps you reproduce and fix issues faster.

Common mistakes

  • Only handling FlutterError.onError: You'll miss async errors and native crashes. Use both FlutterError.onError and PlatformDispatcher.onError.
  • Logging without reporting: Local logs are useless in production. Always send errors to a centralized service.
  • Silent failures in crash handlers: If your crash reporting code throws an error, you'll miss the original error. Wrap reporting calls in try-catch.
  • Ignoring crashes in release mode: Some developers test error handling only in debug mode. Always test in release mode with actual reporting enabled.
  • Showing too much error detail to users: A technical stack trace scares users. Show a friendly message and log the details server-side.
  • Not setting up ErrorWidget.builder: It's easy to forget, but the red screen in production is a deal-breaker. Always provide a fallback UI.

Key takeaways

  • Use FlutterError.onError to catch widget build errors and framework issues.
  • Use PlatformDispatcher.onError to catch uncaught async errors and other exceptions outside the framework.
  • Replace the default red error screen with ErrorWidget.builder and show users a friendly fallback UI.
  • Wire up a crash reporting service (Firebase Crashlytics, Sentry, etc.) to send all errors to a centralized backend.
  • Attach metadata (user ID, feature, version) to crash reports so you can reproduce and prioritize fixes.
  • Test error handling in release mode with actual reporting enabled.
  • Keep error messages user-friendly; log details server-side for your team.
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