Error handling in async code
In an async function try/catch/finally behaves exactly as in synchronous code — on one condition: the error must come from an awaited call.
try { await repo.refresh(); } catch (e) { ... }
await does two things here: it waits for the result and it binds the Future's error to the current frame — the error then behaves as if it had been thrown inside this try block.
Without await the function creates the Future and moves on immediately; the try block finishes, and only afterwards does the Future fail — with no catch left to receive it. This is the "floating future", and it is the single most popular Dart interview question.
finally runs on success, on failure and on rethrow alike — which makes it the right place to release resources (StreamSubscription.cancel(), client.close()).
| Mechanism | What it catches | When to use it |
|---|---|---|
| `try/await/catch` | Synchronous throws and the error of an awaited Future | The default choice — 90% of code is written this way |
| `on FormatException catch (e, st)` | Only errors of that type, together with the stack trace | When different error types need different reactions |
| `future.catchError(...)` | Errors in that Future chain; can be filtered with the `test:` parameter | Where you cannot `await` (e.g. deliberate fire-and-forget with `unawaited(...)`) |
| `future.then(onValue, onError: ...)` | Only the error of the PREVIOUS stage — not an error thrown inside `onValue` | When the two cases must be separated; asked often in interviews for this subtlety |
| `stream.listen(onError: ..., cancelOnError: ...)` | Errors delivered to that subscription (by default the subscription continues) | On the listener side — showing an error, logging |
| `stream.handleError(..., test: ...)` | Errors passing through the pipeline — swallows or transforms them | In the middle of a processing chain, to keep the listener clean |
| `runZonedGuarded(body, onError)` | Every uncaught async error inside the zone | The last line of defence — crash reporting (Sentry, Crashlytics) |
The `Error` vs `Exception` distinction is a convention in Dart, not a language rule — you can throw both, and catch (e) catches both. The difference is intent:
- `Exception` — an expected, recoverable situation: the network dropped, the JSON is malformed, the card was declined. You throw these and the caller catches them.
- `Error` — a programmer bug:
ArgumentError,StateError,RangeError,LateInitializationError,TypeError. These exist to be fixed, not caught. If you write a library, throwingArgumentErrorat a contract-violating call is right; catching one is almost always wrong.
Your own exception class: class PaymentDeclined implements Exception — implements, not extends (Exception is just an interface). Always override toString(), otherwise logs show the useless Instance of 'PaymentDeclined'. Add fields (code, retryable) so callers never have to parse a message string.
`rethrow` and stack traces: catch (e) { throw e; } resets the stack trace and you lose the real origin. rethrow keeps the original trace. If you wrap the error in another type, pass the trace explicitly: Error.throwWithStackTrace(CheckoutFailure(e), st).
Interview tip: you will most likely be shown a snippet with a missing await and asked "why doesn't the catch fire?" Answer with the mechanism, not from memory: "await binds the Future's error to the current frame; without it the try block completes before the Future fails, so the error propagates to the zone and becomes unhandled." Then offer three fixes: add the await, write unawaited(... .catchError(...)) if fire-and-forget is intentional, or collect them with Future.wait. That single answer demonstrates both the language model and the practice.
📚 Sources and documentation
- Error handlingofficialdart.dev