Flutter Error Handling and Crash Reporting: A Practical Setup Guide
The first time an app I shipped crashed in production, I had no idea it happened until a user emailed me. No stack trace, no context, just “the app closes when I open my profile.” I spent an evening trying to reproduce it on my own device before realizing I should have had crash reporting wired up from day one — it’s maybe twenty minutes of setup that saves you from guessing blind every time something breaks for a user you can’t see.
Before getting to that setup, it’s worth understanding something that trips a lot of people up: try/catch doesn’t catch everything Flutter can throw. There are three distinct categories of errors, and each needs its own handler.
The three kinds of errors Flutter throws
Framework errors — thrown during widget building, layout, or painting. A null where a non-null value was expected inside a build() method, for example. These don’t go through your normal try/catch because they happen inside Flutter’s own rendering pipeline, not your function call stack.
Async errors (uncaught exceptions in Futures/Zones) — an exception thrown inside a Future that nobody awaits and nobody attaches a .catchError() to. These silently vanish unless you’re catching them at the zone level.
Platform errors — exceptions crossing the platform channel boundary, from native Android/iOS code back into Dart. These need their own handler too, separate from the two above.
Missing any one of these means a whole category of crashes reports nothing — which is exactly the trap: your crash dashboard looks quiet, so you assume the app is stable, when really you just aren’t hearing about a third of what’s actually breaking.
Catching framework errors
FlutterError.onError catches errors thrown while Flutter is building, laying out, or painting your widget tree — things like a RenderFlex overflowed or a null value hit inside build(). Without this, those errors print to the debug console during development and get silently swallowed in a release build; the screen just goes blank or shows a gray error box, and you never hear about it.
Catching async errors
This is the one people miss most often, because it requires understanding Dart’s Zone concept, not just adding a handler.
runZonedGuarded runs your whole app inside a Zone that catches any exception not otherwise handled — including one thrown inside a Future that nobody awaited. That’s the scenario try/catch structurally can’t cover: if you fire off someAsyncFunction() without await or .catchError(), and it throws, there’s no surrounding try block for the exception to unwind into. It just becomes an “unhandled exception in a Future,” and runZonedGuarded is the only thing that catches those app-wide.
Catching platform (native) errors
PlatformDispatcher.instance.onError catches errors that don’t fit the previous two buckets — things surfacing from the engine or platform side, including some async errors depending on Flutter version. In practice I set up all three (FlutterError.onError, runZonedGuarded, and PlatformDispatcher.instance.onError) together, because they’re not fully redundant with each other and each one has covered a crash the others missed for me at some point.
Wiring it to Firebase Crashlytics
Crashlytics is the easiest option if you’re already using Firebase for anything else, since it shares setup with the rest of the Firebase toolchain.
For non-fatal errors you catch yourself (an API call that failed but you handled gracefully, and still want visibility into how often it happens), log them without marking them fatal:
That last line matters as much as the reporting call. Recording the error for yourself and actually telling the user something went wrong are two different responsibilities — do both. A caught error that reports to Crashlytics but leaves the UI silently stuck on a loading spinner is barely better than not catching it at all.
Sentry as an alternative
Sentry’s Flutter SDK does the same job and is a reasonable pick if you’re not otherwise in the Firebase ecosystem, want self-hosting, or want richer breadcrumb/session-replay features out of the box.
SentryFlutter.init wires up Flutter, async, and platform error capture internally — you don’t need to manually set the three handlers above the way you do with Crashlytics. That’s the main practical difference in setup effort between the two; feature-wise, pick based on whether you want Firebase’s ecosystem or Sentry’s.
Adding context so a crash report is actually useful
A stack trace alone often isn’t enough to reproduce a bug — you need to know what the user was doing. Both tools support attaching breadcrumbs and user context:
Set these at the moments that matter in your flow (before a network call, on screen entry, on a key user action) rather than everywhere — too many breadcrumbs and the useful ones get buried in noise when you’re actually debugging a report.
Frequently asked questions
Does try/catch catch everything if I just wrap my whole app in one? No — you structurally can’t wrap async code that isn’t awaited, and framework/rendering errors don’t happen inside your call stack at all. This is exactly why the three separate handlers above exist.
Should I show users a generic error screen or let the app crash? Neither, ideally — catch the error, report it, and show a specific, honest error state (a retry button, a “something went wrong loading this” message) rather than either a hard crash or a silently broken screen.
Do I need both Crashlytics and Sentry? No, pick one — running both adds SDK overhead and split visibility for no real benefit. Choose based on whether you’re already in the Firebase or Sentry ecosystem.
Will error reporting slow down my app? The overhead is negligible for normal usage — these SDKs are built to batch and send reports asynchronously in the background. It’s not a meaningful performance cost for what you get in return.
Debugging a crash you can’t reproduce, or setting up monitoring for a Flutter app going to production? Get in touch.