Error Handling in Async Dart
This is Part 7 of the Async & Concurrency in Dart series. We can now create and consume futures and streams. But every one of those operations can fail — the network drops, JSON is malformed, a file is missing. Getting error handling right in async code is harder than it looks, because errors travel through time and across event-loop turns. A try/catch that looks correct can silently miss the very error you wrote it for.
This post nails down the rules: when try/catch works, when it doesn't, how Future and Stream errors differ, and how to make sure a failure never vanishes unhandled.
The one rule that explains most bugs
From Part 3, repeated because it's that important:
try/catchonly catches an async error if youawaitthe failing future inside thetry. Noawait, no catch.
// ✅ WORKS — the future is awaited inside the try.
Future<void> good() async {
try {
await riskyFetch(); // error propagates to catch
} catch (e) {
print('caught: $e');
}
}
// ❌ BROKEN — not awaited; error escapes the try entirely.
Future<void> bad() async {
try {
riskyFetch(); // fire-and-forget; try block exits immediately
} catch (e) {
print('never runs'); // the error becomes UNHANDLED, later
}
}
Why? try/catch is synchronous — it only guards code that runs while control is inside the block. Without await, riskyFetch() just starts the work and returns a future; control leaves the try immediately, and the failure surfaces later (a different event-loop turn) with no try around it. The await is what keeps control "inside" the block until the future settles.
try/catch/finally with await
With await, async error handling reads exactly like synchronous error handling — that's the whole appeal:
Future<User> loadUser(String id) async {
showSpinner();
try {
final response = await http.get('/users/$id'); // may throw
final user = User.fromJson(response); // may throw (bad JSON)
return user;
} on SocketException catch (e) {
throw AppError('Network down: $e'); // translate the error
} on FormatException catch (e) {
throw AppError('Bad response: $e');
} catch (e, stack) {
log('unexpected', e, stack); // catch-all + stack trace
rethrow; // don't swallow what you can't handle
} finally {
hideSpinner(); // always runs
}
}
Three things worth calling out:
on Type catch (e)lets you handle specific error types differently (network vs parse vs everything-else).catch (e, stack)gives you theStackTraceas a second parameter — log it; you'll want it.finallyruns whether thetrysucceeded, threw, or yourethrew— the place for cleanup (hide spinners, close files).
rethrow, not throw e
When you catch an error only to log it and pass it on, use rethrow — it preserves the original stack trace. throw e resets the stack to the current line, destroying the trail back to the real cause:
} catch (e) {
log(e);
rethrow; // ✅ keeps original stack trace
// throw e; // ❌ loses where it actually came from
}
Errors with the Future callback API
If you're using .then() instead of await, errors travel down the chain to .catchError() (the catch) and cleanup goes in .whenComplete() (the finally) — as covered in Part 2:
riskyFetch()
.then((data) => process(data))
.catchError((e) => print('caught: $e')) // handles errors from EITHER step
.whenComplete(() => hideSpinner());
A subtlety: .catchError catches errors from everything before it in the chain (the original future and the .then callback). You can also scope it with test: to only catch certain errors:
.catchError(
(e) => fallbackValue,
test: (e) => e is TimeoutException, // only catch timeouts here
)
Mixing await and .catchError is fine, but pick one style per chain for readability — try/catch with await is usually clearer.
Stream errors: different from futures
A Future fails once. A Stream can deliver multiple error events interleaved with data — and, crucially, an error doesn't necessarily end the stream. This changes how you handle them.
With await for → use try/catch
An error event in a stream is thrown out of the await for, so wrap the loop:
Future<void> consume(Stream<int> stream) async {
try {
await for (final value in stream) {
print('data: $value');
}
print('done'); // runs on the done event
} catch (e) {
print('stream error: $e'); // an error event lands here
}
}
⚠️ But note: when an error is thrown out of await for, the loop terminates — you stop consuming the rest of the stream. If you want to keep going after an error, don't use await for; use .listen() with an onError callback (below), or filter errors out first with .handleError().
With .listen() → use the onError callback
.listen() separates the three event types into three callbacks, and cancelOnError decides whether one error stops the subscription:
stream.listen(
(value) => print('data: $value'),
onError: (e, stack) => print('error: $e'), // handle each error event
onDone: () => print('done'),
cancelOnError: false, // false = keep listening after an error (default)
);
| | Future | Stream |
| --- | --- | --- |
| Failures | one, then it's settled | possibly many error events |
| Ends on error? | yes (it's done) | not necessarily (depends on cancelOnError) |
| Catch with await | try/catch around await | try/catch around await for (loop ends on error) |
| Catch with callbacks | .catchError() | .listen(onError: ...) / .handleError() |
handleError — filter errors mid-pipeline
handleError() intercepts errors in the stream pipeline, optionally letting valid data continue:
numbers
.map((n) => 100 ~/ n) // throws on n == 0
.handleError((e) => print('skip: $e')) // swallow the error, keep the stream alive
.listen((v) => print('value: $v'));
The thing that bites everyone: unhandled async errors
An error in a future that nobody is awaiting or catching doesn't crash at the throw site — it becomes an unhandled asynchronous error that bubbles up later, often to the top-level zone (more on zones in Part 9). In Flutter it may print a red error to the console or get reported to FlutterError.onError, but it won't be caught by your local try/catch.
void main() {
// ❌ This throws, but no one awaits/catches it → unhandled async error.
Future.delayed(const Duration(seconds: 1), () => throw 'boom');
// Even adding try/catch around the line above wouldn't help —
// the error happens LATER, after main()'s try block is long gone.
}
Defenses:
- Always
awaitor.catchErrorevery future you create. Turn on theunawaited_futureslint. - For deliberate fire-and-forget, still attach a handler or use
unawaited(...)and accept the risk consciously. - As a safety net, run your app inside
runZonedGuarded(or setFlutterError.onError/PlatformDispatcher.instance.onError) to catch anything that slips through — the subject of Part 9.
// A last-resort catch-all for truly uncaught async errors:
import 'dart:async';
void main() {
runZonedGuarded(() {
runApp(const MyApp());
}, (error, stack) {
log('UNCAUGHT: $error\n$stack'); // report to Crashlytics/Sentry, etc.
});
}
Best practices
- Await inside the
try. Noawait, no catch. - Catch specific types with
on Type catchbefore a genericcatch. rethrow, don'tthrow e, when re-propagating — preserve the stack.- Translate low-level errors into meaningful app errors at boundaries (network → "couldn't load").
- Clean up in
finally/whenComplete— spinners, files, locks. - Decide stream error policy: stop on first error (
await fororcancelOnError: true) vs continue (onError/handleError). - Never leave a future unhandled. Lints + a top-level
runZonedGuarded/FlutterError.onErrorsafety net.
Practice Challenges
Challenge 1 — Fix the silent failure. Make this actually catch the error.
Future<void> run() async {
try {
fetch(); // throws after a delay
} catch (e) {
print('handled: $e');
}
}
Show solution
Future<void> run() async {
try {
await fetch(); // the await is what lets catch see the error
} catch (e) {
print('handled: $e');
}
}
Without await, control leaves the try before fetch fails. No await, no catch.
Challenge 2 — Type-specific handling. Handle TimeoutException and FormatException differently, with a catch-all fallback.
Show solution
Future<void> load() async {
try {
await doWork();
} on TimeoutException {
print('try again later');
} on FormatException catch (e) {
print('bad data: $e');
} catch (e, s) {
log('unexpected', e, s);
rethrow;
}
}
on Type clauses are checked top to bottom; the bare catch is the fallback.
Challenge 3 — Preserve the stack. Why is rethrow better than throw e here?
} catch (e) {
metrics.increment('failure');
throw e;
}
Show solution
throw e creates a new throw at this line, replacing the original stack trace — you lose where the error actually originated. rethrow re-throws the same error with its original stack trace intact, so logs/crash reports still point to the true source. Replace throw e; with rethrow;.
Challenge 4 — Keep the stream alive. A stream of divisors occasionally divides by zero. Print valid results and skip errors without stopping the stream.
Show solution
Stream.fromIterable([2, 0, 4])
.map((n) => 100 ~/ n) // throws on 0
.handleError((e) => print('skipped: $e'))
.listen((v) => print('ok: $v'));
// ok: 50 / skipped: ... / ok: 25
handleError swallows the error event and lets subsequent data flow. (An await for with try/catch would instead stop at the first error.)
Challenge 5 — Catch the uncatchable. This error never hits the try. Explain and give the right safety net.
void main() {
try {
Future.error('async boom');
} catch (e) {
print('caught');
}
}
Show solution
Future.error('async boom') is created but never awaited/handled, and it fails on a later event-loop turn — long after the synchronous try block has exited — so it becomes an unhandled async error. Fix locally by awaiting/.catchError-ing it; as a global safety net, wrap the app in runZonedGuarded((){...}, (e, s) {...}) (Part 9) or set FlutterError.onError/PlatformDispatcher.instance.onError.
Questions to test yourself
Q1 (basic). Complete the rule: "try/catch only catches an async error if ___."
Show answer
"...if you await the failing future inside the try block." Without the await, the future runs fire-and-forget, control leaves the block before it fails, and the error escapes as unhandled. No await, no catch.
Q2 (basic). Which Future callbacks correspond to catch and finally?
Show answer
.catchError() is the catch, and .whenComplete() is the finally (it runs on both success and failure). .then() is the success path.
Q3 (intermediate). How does error handling for a Stream differ fundamentally from a Future?
Show answer
A Future fails at most once and is then settled. A Stream can emit multiple error events interleaved with data, and an error does not necessarily end the stream (controlled by cancelOnError). So you handle stream errors with .listen(onError: ...) / .handleError() (continue) or try/catch around await for (which stops on the first error).
Q4 (intermediate). Why prefer rethrow over throw e when re-propagating a caught error?
Show answer
rethrow re-throws the original error with its original stack trace, preserving the path back to the true source. throw e throws anew from the current line, discarding the original trace and making debugging much harder.
Q5 (advanced). Using await for, an error event occurs mid-stream. What happens to the loop, and what would you use instead to keep consuming?
Show answer
The error is thrown out of await for, so the loop terminates — you stop consuming the remaining events (the error lands in a surrounding try/catch). To keep consuming after an error, use .listen() with an onError callback and cancelOnError: false, or insert .handleError(...) earlier in the pipeline to swallow/transform errors while letting valid data continue.
Q6 (advanced). What is an "unhandled async error", why can't a local try/catch stop it, and what's the global safety net?
Show answer
It's an error from a future/stream that nobody awaited or attached an error handler to. Because the failure occurs on a later event-loop turn — after the synchronous try block has already exited — a local try/catch can't surround it. The fix is to always handle each future (await/catchError, plus the unawaited_futures lint), and as a backstop run the app inside runZonedGuarded (or set FlutterError.onError / PlatformDispatcher.instance.onError) to capture anything that slips through. Zones are covered in Part 9.
Wrapping up
Async errors travel through time, so handling them takes a few firm rules:
- No
await, no catch — await the failing future inside thetry. try/catch/finally(withawait) mirrors.then/.catchError/.whenComplete.- Use
on Type catchfor specific errors andrethrowto preserve stack traces. - Streams differ: multiple error events, may not end on error — choose
await for(stops on error) vs.listen(onError:)/.handleError()(continues). - Unhandled async errors bypass local
try/catch; guard every future and add a top-levelrunZonedGuarded/FlutterError.onErrorsafety net.
That safety net — and the surprisingly deep machinery that makes it possible — runs on a concept we've referenced repeatedly: the zone. But before zones, there's the topic that finally gives us true parallelism for the CPU-bound work async can't help with. Part 8 is Isolates — real multithreading in Dart.