Dart Async Errors That Never Reach Your try/catch: A Diagnostic Guide
When a Dart async exception never reaches your try/catch, the cause is usually an unawaited Future. A diagnostic table, ordered checks, and fixes from await to runZonedGuarded.
25 Oct 2025, 22:32 UTC

An exception thrown inside an async function never reaches the surrounding try/catch. Later, the process logs Unhandled exception with a stack trace full of <asynchronous suspension> frames, or a code path after a failing call silently never runs. In almost every case, the cause is the same: a Future was created but never awaited, so its error was delivered to the zone as an unhandled asynchronous error — decoupled from the call site in both time and stack trace.
Recognizing the condition
The usual symptoms:
- A
try/catcharound an async call catches nothing, even though the called function clearly throws. - The app crashes or logs
Unhandled exceptionfrom a location that looks unrelated to the real bug. - Code after a failing call appears to be skipped, with no error anywhere near it.
The mechanism: calling an async function without await hands back a Future immediately. If that future fails, nothing at the call site is listening, so the error escapes to the zone's unhandled-error handler. The stack trace you eventually see is reconstructed across event-loop turns, which is why it contains <asynchronous suspension> gaps instead of a clean path back to the throw.
Cause and diagnostic table
| Symptom | Likely cause | How to confirm |
|---|---|---|
| try/catch never fires; error surfaces later as unhandled | Async call made without await; the returned Future's error goes to the zone | Inspect the call site: is the call awaited, and is the await inside the try block? |
| Error caught, but only some of the time | await sits outside the try, or only one of several calls is awaited | Check that every fallible call in the block is awaited within the try |
| Deliberate fire-and-forget call occasionally logs unhandled errors | Intentionally unawaited future with no catchError attached | Look for bare async calls; check whether they use unawaited() plus an error handler |
| No missing await found, but awaiters hang forever | A Completer that is never completed | Search for Completer usage; verify every path calls complete() or completeError() |
| StateError from a Completer, mimicking a swallowed error | Completer completed twice | Audit completion paths, especially callbacks that can fire more than once |
The last two rows are the main differential diagnoses: a misused Completer produces symptoms nearly identical to a missing await, so rule it out before assuming the call site is wrong.
Ordered checks
- Confirm the callee is async. Check that the function is declared
asyncand returns aFuture. A synchronous-looking signature hides the problem. - Search call sites for a missing await. Grep for calls to the function and verify each one is awaited, has
.catchErrorattached, or is wrapped inunawaited(). - Run the analyzer with the right lints. Enable
unawaited_futures(and optionallydiscarded_futures) inanalysis_options.yaml, then rundart analyzefrom the project root. This flags accidental unawaited futures mechanically. Lint names and defaults vary by SDK version, so verify the identifiers against your installed analyzer. - Read the stack trace for async gaps.
<asynchronous suspension>frames mark where the trace crosses event-loop turns. The frame above the first gap is usually nearest the original throw — but async traces omit some frames, so treat gaps as a symptom, not proof of location. - Install a top-level handler to surface stragglers. Temporarily wrap
maininrunZonedGuardedand log every unhandled error with its stack. Anything that shows up there is a future nobody is handling.
Fixes tied to each finding
Missing await inside a try
Move the await inside the try block:
Future<void> loadProfile() async {
try {
final profile = await api.fetchProfile(); // await inside try
render(profile);
} on ApiException catch (e) {
showError(e.message);
}
}
Without the await, api.fetchProfile() returns a Future and the catch never sees its failure.
Sequential awaits in a loop that should be concurrent
If the calls are independent, replace the loop with Future.wait:
final results = await Future.wait([
for (final id in ids) api.fetchItem(id),
]);
Note the error semantics before relying on them: whether Future.wait fails fast on the first error or waits for all futures depends on the eagerError flag. Confirm the default in your SDK's documentation, because the wrong assumption reintroduces unhandled errors from the "losing" futures.
Intentional fire-and-forget
Mark it explicitly and attach an error handler:
import 'dart:async';
unawaited(
analytics.track(event).catchError((Object e, StackTrace st) {
log.warn('analytics failed', e);
}),
);
unawaited() was added in Dart 2.15; on older SDKs, restructure the call or use an equivalent helper. The point is twofold: the error goes somewhere deliberate, and the unawaited_futures lint stops flagging the call — so it can keep flagging the accidental ones.
Last‑resort net
Add runZonedGuarded around main (or, in Flutter, the platform dispatcher error hook) to catch anything that escapes local handling. Treat this as a safety net for logging and crash reporting, not a substitute for handling errors where they occur — by the time an error reaches the zone, the original context is gone. Flutter's recommended global hook has changed across releases (FlutterError.onError versus PlatformDispatcher.instance.onError), so check the current Flutter error‑handling guidance for your version.
Verifying the fix
Write a minimal script with an async function that throws, call it with and without await inside a try/catch, and run it with dart run. You should see the awaited version caught and the unawaited version reported as unhandled. Then enable unawaited_futures and confirm dart analyze flags a deliberately unawaited call. Run dart --version and check the SDK docs for unawaited(), Future.wait, and runZonedGuarded to confirm the behaviors described here match your installed version.
When to escalate
Take the problem further when:
- The errors originate inside a third‑party package, platform channel, or native code you don't control — capture a minimal reproduction and report upstream.
- Unhandled errors persist even with a zone‑level handler installed, which suggests the failure happens outside your zone entirely.
- The failure is timing‑dependent. That points to event‑loop or microtask ordering, not a missing
await; reproduce it in a minimal async test (testpackage,expectLateron the future) before filing anything.
Most cases never get past check three: the analyzer finds the bare call, you add the await, and the "impossible" unhandled exception disappears.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.