Error Handling in Flutter Apps: Typed Failures, Human Messages and Global Handlers
Error handling in Flutter apps: exceptions vs errors in Dart, typed failures, user-friendly messages, retry UX, global handlers and a safe ErrorWidget.

“Something went wrong.” Every app shows this message eventually. The question is whether anyone, the user or the developer, can do anything useful with it afterward.
When I take over a Flutter codebase, error handling is one of the first things I look at, because it tells me a lot about how the app will behave on a bad day. Usually I find a mix of try { ... } catch (e) { print(e); }, raw exception text shown to users, and a gray screen in release builds that nobody has seen because it only happens on someone else’s phone.
Here are the lessons I apply to every app now, roughly in the order they matter.
1. Know the difference between an exception and an error
Dart has two families of throwables, and they mean different things:
Exceptionis for things that can go wrong at runtime even in correct code: the network is down, a file is missing, the server returned garbage. You’re expected to catch and handle these.Erroris for programming mistakes: a null check on a null value, an index out of range, a bad cast, anassertthat failed. You’re not supposed to catch these and carry on. You’re supposed to fix the code.
That distinction shapes everything else. A blanket catch (e) treats a StateError from a bug the same as a SocketException from a tunnel. The first should crash loudly in development and get reported in production. The second should show a retry button.
When I catch, I catch specific types:
try {
return await _api.fetchProfile();
} on SocketException {
throw const NetworkFailure();
} on TimeoutException {
throw const NetworkFailure();
} on FormatException catch (e, stack) {
// The server sent something we can't parse. That's our problem to fix.
_reporter.record(e, stack);
throw const UnexpectedFailure();
}
Anything not listed keeps propagating, which is exactly what I want.
2. Turn technical errors into typed failures at the edge
Your UI shouldn’t know what a DioException or a FirebaseAuthException is. Those are details of the data layer. I convert them into a small set of app-level failures right where they enter the app, in the repository:
sealed class Failure {
const Failure();
}
final class NetworkFailure extends Failure {
const NetworkFailure();
}
final class UnauthorizedFailure extends Failure {
const UnauthorizedFailure();
}
final class ValidationFailure extends Failure {
const ValidationFailure(this.fieldErrors);
final Map<String, String> fieldErrors;
}
final class UnexpectedFailure extends Failure {
const UnexpectedFailure();
}
Because the class is sealed, a switch over a Failure has to cover every case. Add a new failure type and the compiler points at every screen that needs to handle it.
Some teams go one step further and return a Result type instead of throwing, so a repository method’s signature says it can fail:
sealed class Result<T> {
const Result();
}
final class Ok<T> extends Result<T> {
const Ok(this.value);
final T value;
}
final class Err<T> extends Result<T> {
const Err(this.failure);
final Failure failure;
}
I like this for repositories with several failure modes, because callers can’t forget to handle the error branch. For simpler apps, throwing typed failures and catching them in the state layer is fine too. Pick one and stay consistent. I go into where these types live in pragmatic clean architecture in Flutter.
3. Write messages for humans, in one place
Once failures are typed, mapping them to messages is a simple function. I keep it in one place, so wording stays consistent and translations are easy:
String messageFor(Failure failure) => switch (failure) {
NetworkFailure() =>
'You seem to be offline. Check your connection and try again.',
UnauthorizedFailure() => 'Your session has expired. Please sign in again.',
ValidationFailure() => 'Some fields need your attention.',
UnexpectedFailure() =>
'We couldn\'t complete that. Please try again in a moment.',
};
A good error message says what happened in plain words and what the user can do next. It never shows a stack trace, an HTTP status code or the text of an exception. “SocketException: Failed host lookup” means nothing to someone booking an appointment, and it leaks details about your backend.
4. Make retry the default, not an afterthought
Most errors in a mobile app are temporary. The user walked into an elevator, the server hiccupped, a token was refreshing. So the default response to a failed load should be a clear retry option, not a dead end.
A few rules I follow:
- Full-screen load failed: show the message and a Retry button in place of the content.
- Refresh failed but old data exists: keep showing the old data, and show a small banner or SnackBar. Don’t wipe a working screen because a background refresh failed.
- An action failed (save, delete, send): keep the user’s input. Nothing is more frustrating than retyping a form because the network dropped.
- Unauthorized: don’t offer retry. Send the user to sign in, ideally returning them to where they were afterward.
If the app should keep working without a connection at all, that’s a bigger design decision. I cover it in offline-first Flutter apps.
5. Install global handlers on day one
Not every error passes through your repositories. Some happen during layout, inside a plugin, or in an async callback nobody awaited. Flutter gives you two hooks to catch them:
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Errors thrown by the Flutter framework: build, layout, paint, gestures.
FlutterError.onError = (details) {
FlutterError.presentError(details); // keep the console output in debug
reporter.recordFlutterError(details);
};
// Uncaught async errors that never reached the framework.
PlatformDispatcher.instance.onError = (error, stack) {
reporter.record(error, stack, fatal: true);
return true; // handled
};
runApp(const App());
}
reporter here stands for whatever crash reporting you use. Firebase Crashlytics and Sentry both plug into these hooks, and I compare them in crash reporting and monitoring in Flutter. The important part is that these handlers exist from the first build. An error you never hear about is one you can’t fix.
6. Replace the gray box in release builds
When a widget throws during build, Flutter replaces it with an ErrorWidget. In debug mode that’s the famous red screen with the message. In release mode it’s a plain gray box, which looks like the app is broken, because it is.
You can’t prevent every build error, but you can make the fallback less alarming:
void main() {
WidgetsFlutterBinding.ensureInitialized();
if (kReleaseMode) {
ErrorWidget.builder = (details) => const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'This part of the screen couldn\'t be displayed.',
textAlign: TextAlign.center,
),
),
);
}
runApp(const App());
}
I only override it in release. In development I want the red screen, loud and impossible to miss. Keep the replacement simple, too. If it depends on theme or localization that might be what’s broken, you get an error while rendering the error.
This is a safety net, not a fix. The real error still goes to FlutterError.onError and your crash reporter, and it still needs to be fixed.
7. Never swallow errors silently
This is the rule I’d tattoo on a codebase if I could. An empty catch block, or one that only calls print, hides problems until they become support tickets.
// Please don't.
try {
await syncCart();
} catch (_) {}
If you genuinely want to ignore an error, which does happen, say so and still record it:
try {
await analytics.logEvent('cart_viewed');
} catch (e, stack) {
// Analytics must never break checkout, but we still want to know.
reporter.record(e, stack, fatal: false);
}
The comment explains the decision to the next developer. The report means the decision doesn’t hide a real problem. It’s the same reasoning I apply in code reviews: every catch should make clear what it’s catching and why.
8. Test the unhappy paths
Error handling that’s never tested tends to break quietly. For every repository, I write at least one test where the fake data source throws, and check that the right Failure comes out. For every screen with a load state, I write a widget test that shows the error view and taps Retry. These tests are cheap, and they catch the regression where someone refactors the happy path and the error branch silently stops working.
Takeaways
- Catch
Exceptions you can handle. LetErrors, which are bugs, surface and get reported. - Convert technical exceptions into a small sealed set of failures in the data layer.
- Map failures to plain-language messages in one function, with a clear next step for the user.
- Keep old data and user input when something fails, and make retry the default.
- Set
FlutterError.onError,PlatformDispatcher.instance.onErrorand a release-onlyErrorWidget.builderbeforerunApp. - No empty catch blocks. If you ignore an error, comment why and record it anyway.
If your app’s crash reports are a mystery and users keep seeing “Something went wrong”, I’m happy to take a look. Get in touch.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).