Zones in Dart
This is Part 9 of the Async & Concurrency in Dart series — the final concept before the mastery bank. Zones are the most mysterious part of async Dart: rarely taught, occasionally crucial, and the thing that makes runZonedGuarded, global error reporting (Crashlytics/Sentry), and even Flutter's own startup work. In Part 7 we promised a "safety net" for unhandled async errors. That net is a zone.
You won't write zones every day. But understanding them turns several pieces of "Flutter magic" into something you can reason about — and it's a favorite senior-level interview topic.
What is a zone?
A zone is an execution context that stays attached to your code across asynchronous gaps.
Here's the problem it solves. A normal try/catch is synchronous — it only wraps code that runs right now, in this call stack. But async callbacks (a Future completing, a Timer firing) run later, on a fresh call stack, with no try/catch around them (Part 7). So how do you wrap everything that happens as a consequence of some code — including all its future async callbacks?
A zone. Think of it as an invisible context that travels with your async work. When you start an operation inside a zone, every callback it later schedules — through any number of awaits, timers, and microtasks — stays in that same zone. So a zone can install one error handler that catches failures from the whole tree of async work, no matter how far in the future they fire.
Analogy: a
try/catchis a net under one trapeze. A zone is a net under the entire circus — every act, including the ones that start hours later.
Every line of Dart runs in some zone. The default is the root zone (Zone.root); Zone.current always tells you which zone you're in.
runZonedGuarded: the async safety net
The headline use of zones is catching errors that a try/catch can't — the unhandled async errors from Part 7. runZonedGuarded runs a block of code in a new zone with an error handler that catches everything that escapes, even from deeply nested async callbacks:
import 'dart:async';
void main() {
runZonedGuarded(() {
// Anything async started in here is "in the zone".
Future.delayed(const Duration(seconds: 1), () {
throw 'async boom'; // not awaited, not caught locally...
});
Timer(const Duration(seconds: 2), () {
throw 'timer boom';
});
}, (error, stack) {
// ...but this handler catches it. Both booms land here.
print('Caught by zone: $error');
});
}
Recall from Part 7 that a plain try/catch around those throws would do nothing — the errors fire on later event-loop turns. The zone's onError handler catches them because the callbacks still belong to the zone, however far in the future they run. This is the canonical place to report crashes to a logging service:
runZonedGuarded(() {
runApp(const MyApp());
}, (error, stack) {
Crashlytics.recordError(error, stack); // report EVERY uncaught async error
});
Flutter's two error channels (and where zones fit)
Flutter actually has two error pathways, and you usually want both wired to your reporter:
void main() {
runZonedGuarded(() {
WidgetsFlutterBinding.ensureInitialized();
// 1) Errors from inside the Flutter framework (build/layout/paint).
FlutterError.onError = (details) {
reporter.record(details.exception, details.stack);
};
runApp(const MyApp());
}, (error, stack) {
// 2) Uncaught errors from your async Dart code (outside the framework).
reporter.record(error, stack);
});
}
FlutterError.onErrorhandles errors within the framework's synchronous machinery (a badbuild, an overflow).runZonedGuarded's handler catches async errors that escape your own code.
Modern note: newer Flutter also exposes
PlatformDispatcher.instance.onErroras a simpler catch-all for uncaught async errors, often used instead ofrunZonedGuarded. ButrunZonedGuardedremains the canonical, framework-version-agnostic way to understand the mechanism — and it's what interviewers ask about.
This is the concrete answer to "how does Flutter use zones internally": it runs your app inside a zone so that no async error can silently disappear, routing them to a place you can log and report.
Beyond errors: what else a zone can do
Errors are the headline, but a zone can intercept and override several runtime behaviors for all code running inside it. You create such a zone with runZoned and a ZoneSpecification:
Override print (e.g. add timestamps, or capture logs)
import 'dart:async';
void main() {
runZoned(() {
print('hello');
print('world');
}, zoneSpecification: ZoneSpecification(
print: (self, parent, zone, line) {
parent.print(zone, '[${DateTime.now()}] $line'); // decorate every print
},
));
}
Every print inside the zone is routed through your handler. This is how test frameworks capture output and how some loggers timestamp everything without touching call sites.
Zone-local values (implicit context)
A zone can carry values accessible to all code inside it — like a thread-local, but for async. Useful for passing a request ID, a user context, or a trace ID through a deep async call chain without threading it through every function signature:
import 'dart:async';
void handleRequest(String requestId) {
runZoned(() {
processStep1(); // can read Zone.current[#requestId] without being passed it
}, zoneValues: {#requestId: requestId});
}
void processStep1() {
print('handling request ${Zone.current[#requestId]}');
}
Anywhere inside that zone — through any number of async hops — Zone.current[#requestId] returns the value. This is how structured logging frameworks attach context to every log line in a request.
Intercept scheduling (advanced)
A ZoneSpecification can also intercept scheduleMicrotask, timer creation, and callback registration — which is how test frameworks implement fake async (e.g. fakeAsync), fast-forwarding timers without real waiting. You'll rarely write this yourself, but it explains how those tools work.
Zones are nested
Zones form a tree. runZoned/runZonedGuarded fork a child from the current zone, inheriting its behavior except what you override. An error (or a print, or a value lookup) bubbles up to the nearest zone that handles it — much like try/catch nesting, but across async boundaries. This nesting is why a zone-local value set in an outer zone is visible to inner zones, and why an inner zone can install a more specific error handler than the root.
When should you actually use zones?
Honestly: rarely, and mostly at the app's entry point. A practical guide:
| Situation | Use a zone? |
| --- | --- |
| Global crash reporting (catch all uncaught async errors) | ✅ Yes — runZonedGuarded (or PlatformDispatcher.onError) |
| Propagating a request/trace ID through deep async code | ✅ Sometimes — zone-local values |
| Capturing/decorating all print output (logging, tests) | ✅ Occasionally |
| Ordinary feature code, error handling in a function | ❌ No — use try/catch with await (Part 7) |
Don't reach for zones to handle expected errors. A network call that might fail should use
try/catchright there. Zones are for the cross-cutting, last-resort, app-wide concerns — catching the unexpected, not the routine.
Practice Challenges
Challenge 1 — Catch an uncaught async error. Use runZonedGuarded so a non-awaited throwing future is still reported.
Show solution
import 'dart:async';
void main() {
runZonedGuarded(() {
Future(() => throw 'oops'); // not awaited
}, (error, stack) {
print('reported: $error'); // reported: oops
});
}
The zone's handler catches what a local try/catch could not.
Challenge 2 — Timestamped prints. Make every print inside a block prefixed with a counter or timestamp, without changing the print calls.
Show solution
import 'dart:async';
void main() {
var n = 0;
runZoned(() {
print('a');
print('b');
}, zoneSpecification: ZoneSpecification(
print: (self, parent, zone, line) => parent.print(zone, '#${++n}: $line'),
));
// #1: a
// #2: b
}
The zone intercepts print; call sites are untouched.
Challenge 3 — Pass context implicitly. Use a zone-local value to make a deep helper read a userId it was never passed.
Show solution
import 'dart:async';
void deepHelper() => print('user = ${Zone.current[#userId]}');
void main() {
runZoned(() {
deepHelper(); // user = u-42
}, zoneValues: {#userId: 'u-42'});
}
Zone.current[#userId] reads the value anywhere inside the zone, across async hops.
Challenge 4 — Wire up Flutter crash reporting. Sketch a main() that reports both framework errors and uncaught async errors.
Show solution
void main() {
runZonedGuarded(() {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (d) => reporter.record(d.exception, d.stack);
runApp(const MyApp());
}, (error, stack) => reporter.record(error, stack));
}
FlutterError.onError covers framework errors; the zone handler covers async ones.
Challenge 5 — Right tool? For "an API call in a button handler might fail and I want to show a snackbar," should you use a zone? Why or why not?
Show answer
No. That's an expected, local error tied to a specific operation — handle it with try/catch around the await right there in the handler. Zones are for cross-cutting, app-wide, last-resort concerns (catching the unexpected and reporting crashes), not routine per-call error handling.
Questions to test yourself
Q1 (basic). In one sentence, what is a zone?
Show answer
A zone is an execution context that stays attached to your code across asynchronous gaps, so behaviors like error handling, print, and context values apply to all callbacks that code later schedules — not just the synchronous part.
Q2 (basic). Which function runs code in a zone that catches uncaught async errors, and what's its handler signature?
Show answer
runZonedGuarded(body, onError), where onError is (Object error, StackTrace stack). Any error that escapes the body — including from non-awaited futures and timers — is routed to onError.
Q3 (intermediate). Why can a zone catch an error that a surrounding try/catch cannot?
Show answer
A try/catch is synchronous — it only guards code running in the current call stack. Async callbacks fire later on a fresh stack with no try/catch around them. A zone, however, stays attached to all callbacks scheduled within it, however far in the future, so its error handler still applies when those callbacks throw.
Q4 (intermediate). What are zone-local values good for, and how do you read one?
Show answer
They carry implicit context (a request ID, user, trace ID) to all code inside a zone without passing it through every function parameter — like an async-aware thread-local. You set them via runZoned(..., zoneValues: {#key: value}) and read them with Zone.current[#key] anywhere inside the zone, across async hops.
Q5 (advanced). How does Flutter use zones, and what are the two error channels you'd typically wire up?
Show answer
Flutter runs your app inside a zone so no async error vanishes silently. You typically wire two channels: FlutterError.onError for errors inside the framework's synchronous machinery (build/layout/paint), and runZonedGuarded's onError (or PlatformDispatcher.instance.onError) for uncaught async errors from your own Dart code. Both are routed to your crash reporter (Crashlytics/Sentry).
Q6 (advanced). Besides error handling, name two other behaviors a ZoneSpecification can override, and a real use for each.
Show answer
(1) print — intercept all console output to add timestamps/prefixes or capture logs (used by loggers and test frameworks). (2) Scheduling (scheduleMicrotask, timer/callback creation) — intercept to implement fake async in tests (e.g. fakeAsync), fast-forwarding timers without real delays. (Zone-local values are a third: implicit context propagation.)
Wrapping up
Zones are the quiet backbone of robust async Dart:
- A zone is an execution context that follows your async work, so one handler can govern a whole tree of future callbacks.
runZonedGuardedis the safety net for uncaught async errors thattry/catchcan't reach — the home of global crash reporting.- Flutter runs your app in a zone and pairs it with
FlutterError.onError(framework errors) — wire both to your reporter. - A
ZoneSpecificationcan also overrideprint, scheduling, and carry zone-local values for implicit context. - Use zones for cross-cutting, last-resort concerns — not routine, expected errors (those stay
try/catch).
That completes the concepts. You now understand the event loop, futures, async/await, streams, StreamController, generators, error handling, isolates, and zones — the entire async and concurrency surface of Dart. Time to prove it. Part 10 is a 100-question mastery bank with coding mini-exercises, basic → advanced, with hints and solutions.