DEV Community

Quan Nguyen
Quan Nguyen

Posted on

Your Flutter App Is Hiding Its Own Bugs

TL;DR. One rule: catch only the failures you can act on, and let everything else reach your crash tool untouched. Expected failures get a type, a true sentence and a count. Bugs are never caught, so they arrive in Sentry with the line they were actually thrown from. The code is public in bp-bloc, bp-riverpod and bp-mvvm.

I have been writing Flutter since 2018 and web since 2002. The worst bugs I have shipped were not the ones that crashed. They were the ones nobody told me about. Users hit them, the app caught the error and showed a calm "Something went wrong", and for a month my dashboard said zero and I believed it.

You might be fine. If your app is small and every catch in it was written on purpose, for one specific failure you had in mind, you can stop reading now.

But here is how most apps get there. Someone wraps a call in try/catch to stop a red screen during a demo. Someone copies it. A year later there are forty of them, most of them catch (e), and every one is a hole your bugs fall into quietly.

The cost is a support ticket nobody can reproduce, a backend contract that broke nine days ago, and a quarter you planned on a dashboard that was lying to you.

Here is how we fix it in the monorepo we start client projects from. Every file below is one you can open.


The whole design, in one rule

Sort every failure into two piles. Expected ones you already know about: network down, session expired, email already registered, a 500. Tell the user something true and carry on. Unexpected ones are bugs, and the only correct response is to find out.

A catch (e) cannot tell them apart. It grabs your SocketException and your null dereference, apologises identically for both, and the second one never gets back to you.

Catch only what you can do something about. Let everything else keep going. Everything below is the machinery that makes that hold.


Expected failures get a type

sealed class AppException implements Exception {
  const AppException({this.message, this.code, this.cause, this.stackTrace});
  final String? message;      // for logs, never shown verbatim
  final String? code;         // HTTP status or backend code
  final Object? cause;        // the original error
  final StackTrace? stackTrace; // where [cause] was thrown
}
Enter fullscreen mode Exit fullscreen mode

Subtypes are the vocabulary: NetworkException, ServerException, UnauthorizedException, ForbiddenException, NotFoundException, ValidationException, CacheException, UnknownException, and DisplayableException for copy that is already translated.

It is sealed, so the switch that turns an exception into a sentence has to cover every case. Add a failure type and the compiler tells you where the copy is missing, instead of a user finding out for you. And it keeps cause and stackTrace, which sounds like bookkeeping and is the difference between a crash report you can act on and one that points at your own error handling.


Translate once, and keep the real trace

Every repository extends BaseRepository and wraps its calls in guard:

@protected
Future<T> guard<T>(Future<T> Function() body) async {
  try {
    return await body();
  } catch (error, st) {
    Error.throwWithStackTrace(handleException(error, st), st);
  }
}
Enter fullscreen mode Exit fullscreen mode

That one call is the difference between a useful crash report and a useless one.

handleException does the mapping, and it is boring on purpose. Its first line is if (error is AppException) return error;, which is what makes "translate once" true: anything already classified passes straight through, because re-classifying it only throws information away.

The line I got wrong

FormatException maps to ServerException. I shipped it as ValidationException first, and wrote the reason into the code so nobody puts it back:

A FormatException reaching here came from decoding a payload, never from user input: input is validated before the call, and a rejected field arrives as a ValidationException from ApiClient's 400/422 branch. So this is a broken client/server contract.

One wrong line, two bad outcomes. We told users to check input they could not change, and we built a chart saying our users were typing things wrong when our backend had quietly changed a field type. The type decides what the user reads and whose fault the dashboard thinks it is.


One job per layer

Six places touch an error on the way through. Each has exactly one job, and doing someone else's job is how the chain breaks.

Two notes the diagram cannot hold. ApiClient maps status codes but never transport failures: no connection means no status, so that is guard's job. Map status codes in two places and six months later the two lists disagree.

And it decodes the error body inside a try, because your contract covers what your backend sends, not the HTML page a proxy returns when your backend is down. If the body will not parse, the status becomes the code: a broken error response should never be worse than the error it was describing. One caveat on the starter itself. It probes error.code, then code, then error_code, because it does not know which backend you will point it at. Your contract names exactly one of those. Collapse it to that one key, because probing for three shapes in an app that only ever talks to one backend is a smell, not a feature.


The failure you will never notice

The diagram lists what each layer must never do. Break the first three and you get a crash report pointing at nothing, a real bug rendered as "Something went wrong", or HTTP 500: NullPointerException at UserService.java:412 shown to a user, which is frightening for them and occasionally a security problem for you. Unpleasant, but you find out.

The fourth stays hidden. New copy is a backend code plus an ARB entry, never a second switch on exception type somewhere else. Break it and this happens. Someone adds a helper in one feature that maps exceptions to strings, because the generic message was not good enough for that screen. It ships, and it shadows toUserMessage for that path. Every error* string in your ARB files that only that path could reach is now dead. Translators keep translating them. Tests keep passing. Copy you paid to have written in nine languages is never read by anyone.


The two lanes

Future<void> handleBlocAction(
  Future<void> Function() action, {
  required void Function(AppException failure) onFailure,
}) async {
  final invokedAt = StackTrace.current;
  try {
    await action();
  } on AppException catch (failure) {
    unawaited(_reporter.reportHandled(
      failure,
      handledAt: StackTrace.current,
      invokedAt: invokedAt,
    ));
    onFailure(failure);
  }
}
Enter fullscreen mode Exit fullscreen mode

on AppException, not catch. A bug thrown inside action() is not caught here at all. It keeps going, into the global net. This is the piece that differs between the three repos: guardAppException is a Ref extension folding the failure into an AsyncValue in bp-riverpod, Command.run does it in bp-mvvm.

reportHandled is the other half. Rule 2 stops you hiding bugs; breadcrumbs tell you when an expected failure is happening far more often than it should. As the doc comment puts it: a 5xx that a user experienced as generic copy is still a defect, and nothing else in the app would ever tell you it happened.

The two stack traces are worth the ugliness. AppException.stackTrace says where the failure was thrown; handledAt says what absorbed it; invokedAt says what set the action off, and it needs its own capture before the await because an async trace keeps only the frames that are awaiting, so the caller is usually gone by the time you reach the catch. Together they answer a question a crash report cannot: not just how often this breaks, but whether whatever is currently absorbing it is good enough. bp-riverpod does the same job from AppProviderObserver rather than here, because assigning an AsyncError to a notifier already fires providerDidFail, and reporting from both places filed every handled failure twice.

There is deliberately no error Zone

Almost every Flutter tutorial wraps runApp in runZonedGuarded. We do not. A zone gives you two nets instead of one: it grabs errors thrown inside it before PlatformDispatcher.onError ever gets a look, so which net catches an error depends on where it was thrown. Nobody reasons about that correctly at three in the morning.

Worse: if the code inside the zone is async and throws, runZonedGuarded drops the future it gave you. main() waits forever, the app never starts, and nothing is reported, because the reporter is the thing that hung.

If you need zones for request-scoped values you might need one anyway. For catching errors, PlatformDispatcher.onError does it with one net and no hang.


The sink is an interface, not a vendor

core never imports Sentry. bootstrap owns the reporter: it builds a ConsoleErrorReporter unless your app passes one, installs that same object in the global error net, and registers it once initializer() has run, so the handled lane resolves the very same instance. One door, not two.

To send both lanes somewhere real, you write one method:

class SentryErrorReporter extends ErrorReporter {
  const SentryErrorReporter();

  @override
  Future<void> report(
    Object error,
    StackTrace stackTrace, {
    StackTrace? handledAt,
    StackTrace? invokedAt,
  }) async {
    if (handledAt == null) {
      await Sentry.captureException(error, stackTrace: stackTrace);
      return;
    }
    Sentry.addBreadcrumb(
      Breadcrumb(
        message: '$error',
        data: {'handledAt': '$handledAt', 'invokedAt': '$invokedAt'},
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Then one line in whichever main_.dart should use it: reporter: const SentryErrorReporter().

Both lanes arrive at report, and handledAt is what tells them apart. Null means nothing turned this into state, so it is a crash. Non-null names the code that absorbed it. There is deliberately no handled boolean, because its one honest value was always handledAt != null, and two sources for one fact drift apart.

Note extends, not implements. reportHandled is concrete on ErrorReporter and funnels into report, so extending gets you that for free. implements takes the interface and not the implementation, and puts both methods back on your plate.

The default logs rather than discards, because a generated app runs with no backend either way and this one at least tells you a crash happened. NoopErrorReporter is still there for where reporting is noise: a test that fails on purpose, or a flavor pointed at a fake backend.

And a confession, since the obvious way to get a reporter into a state holder is a process global and that is exactly what we shipped first. It worked and it always felt wrong. It is now resolved from the container, guarded so that core's own tests and the component demo still run without bootstrap at all. An unguarded lookup would throw from inside the failure path and turn a handled failure into a crash, which is a funny way to lose an afternoon.


What it costs

Every new backend error code needs a sentence in every language you ship, and you will forget one, so the generic fallback still has to be decent.

It is also more typing: guard, a typed exception, a use case, handleBlocAction and an ARB entry, where a quick try/catch is five lines. That feels like a lot on the first feature. It stops feeling like a lot the first time someone asks why a user saw an error last Tuesday and you can actually answer.

Where I could be wrong

You cannot always sort the piles up front. Some backends send a 200 with the error hidden in the body, and the code I have written for one of those is uglier than anything here.

Resolving the reporter from the container is a lookup, not an injection. It keeps blocs from carrying a reporter they do not otherwise use, and it is a service locator, which plenty of people will tell you is the wrong answer. I think the alternative is worse here. You might not.

Sealing AppException costs more than I made it sound. Your own exception types now have to extend a sealed class that lives in another package, which is more awkward than it looks. A big app with a rich error domain might do better with an open hierarchy.

And the big one: the apps we build on this are private, so when I say a rule stopped a real bug, you cannot check.


The one thing to take away

Go find every catch (e) and catch (_) in your state holders and ask what each one is protecting the user from. Most of them were put there to stop a red screen in a demo two years ago.

Every one you swap for a typed catch turns a bug you were hiding into a bug you will hear about. That is the entire trade, and it is worth making.


Public code: bp-bloc (packages/core/lib/src/domain/exceptions/, data/repositories/base_repository.dart, presentation/bloc/bloc_error_handling.dart, presentation/error/error_messages.dart, error/error_reporter.dart), bp-riverpod (presentation/riverpod/async_error_handling.dart), bp-mvvm (presentation/listenable/command.dart). The domain and data layers are byte-identical across all three; the presentation helper and the observer are what differ.

Beaupixel builds and ships product for startups. If you want a foundation like this on your own app, talk to us.

Top comments (0)