Skip to content

Production Concerns

Catch every error — and get the current mechanism right

Section titled “Catch every error — and get the current mechanism right”

In production you want to hear about crashes before your users complain. This is one of the places official Flutter guidance genuinely changed, and getting the current recommendation right (not the one from a couple of years ago) matters.

FlutterError.onError: framework errors, proven

Section titled “FlutterError.onError: framework errors, proven”
lib/error_demo.dart
import 'package:flutter/material.dart';
class ThrowsInBuild extends StatelessWidget {
const ThrowsInBuild({super.key});
@override
Widget build(BuildContext context) {
throw StateError('build error');
}
}
test/error_demo_test.dart
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/tooling-and-production__production-concerns/error_demo.dart';
void main() {
testWidgets(
'FlutterError.onError catches a build() error; PlatformDispatcher.onError does not',
(tester) async {
FlutterErrorDetails? caughtByFlutterError;
Object? caughtByPlatformDispatcher;
final originalFlutterOnError = FlutterError.onError;
final originalPlatformOnError = PlatformDispatcher.instance.onError;
FlutterError.onError = (details) => caughtByFlutterError = details;
PlatformDispatcher.instance.onError = (error, stack) {
caughtByPlatformDispatcher = error;
return true;
};
await tester.pumpWidget(const MaterialApp(home: ThrowsInBuild()));
FlutterError.onError = originalFlutterOnError;
PlatformDispatcher.instance.onError = originalPlatformOnError;
expect(caughtByFlutterError?.exception, isA<StateError>());
expect(caughtByPlatformDispatcher, isNull); // genuinely NOT forwarded here
expect(tester.takeException(), isNull); // FlutterError.onError handled it, nothing escaped
});
}
00:00 +0: FlutterError.onError catches a build() error; PlatformDispatcher.onError does not
00:00 +1: All tests passed!

Proven directly: a build/layout/paint error goes to FlutterError.onError and stays there — PlatformDispatcher.instance.onError never sees it, and tester.takeException() returns null because FlutterError.onError fully absorbed it (the same “absorbed vs. escaped” distinction proven for FutureBuilder in the futures-streams-in-ui lesson).

PlatformDispatcher.instance.onError: the current answer for everything else

Section titled “PlatformDispatcher.instance.onError: the current answer for everything else”

For years, the standard advice (and this lesson’s own earlier version) was to pair FlutterError.onError with a hand-rolled runZonedGuarded around runApp() to catch async errors. That advice is now out of date, straight from Flutter’s own 3.3 release notes, quoted directly: “In previous releases, you had to manually configure a custom Zone in order to catch all of an application’s exceptions and errors. However, custom Zones were detrimental to a number of optimizations in Dart’s core libraries, which slowed down application start-up time. In this release, instead of using a custom Zone, you should catch all errors and exceptions by setting the PlatformDispatcher.onError callback.” docs.flutter.dev’s error-handling guide confirms the specific gap PlatformDispatcher.instance.onError closes: “Consider an onPressed callback that invokes an asynchronous function… If invokeMethod throws an error, it won’t be forwarded to FlutterError.onError. Instead, it’s forwarded to the PlatformDispatcher.”

void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
reportToCrashService(details.exception, details.stack);
};
PlatformDispatcher.instance.onError = (error, stack) {
reportToCrashService(error, stack); // the current, official replacement for runZonedGuarded
return true; // tells the engine the error was handled
};
runApp(const MyApp());
}

An honest, tested gap in this course’s own environment: proving PlatformDispatcher.instance.onError actually fires for a genuinely uncaught async error (e.g. an unawaited Future that throws) could not be reproduced under flutter test — real, run attempts here showed package:test’s own test zone (and, under testWidgets, its FakeAsync-based binding) intercepts an uncaught async error for its own test-failure reporting before it would ever reach a custom PlatformDispatcher.instance.onError, in both a plain test() and a testWidgets() body. This is a genuine tooling limitation of the test environment, not a claim about real app behavior — the official docs quoted above are unambiguous that PlatformDispatcher.instance.onError is what a real, running app (outside any test harness) receives for this error class. runZonedGuarded itself still works exactly as proven in the Async in Dart lesson — it is not deprecated or broken — it is simply no longer the recommended mechanism for this specific job, for the startup-performance reason quoted above.

Wire both handlers above to a service — Sentry, Firebase Crashlytics — so real-device crashes come back with a stack trace, device info, and breadcrumbs:

// Code shape (Sentry), cited from sentry.io's Flutter docs — not
// independently run here, no external network/service in this environment.
PlatformDispatcher.instance.onError = (error, stack) {
Sentry.captureException(error, stackTrace: stack);
return true;
};

Two rules that matter regardless of provider:

  • Upload symbols/debug info for release builds — AOT strips symbols, and if --obfuscate is used, identifiers are renamed too; without the exact matching symbol file, stack traces are unreadable (proven in full, with a real --split-debug-info build and real symbol files, in the build-and-release lesson).
  • Log deliberately, and know what actually survives release — this course’s earlier version claimed print is “stripped/ignored in release.” Reading the installed SDK source directly (~/flutter/packages/flutter/lib/src/foundation/print.dart) disproves that: neither print nor debugPrint is stripped in release. debugPrint’s own doc comment is explicit: “The debugPrint function logs to console even in release mode. As per convention, calls to debugPrint should be within a debug mode check or an assert.” The real reason to prefer debugPrint over bare print isn’t release-stripping — it’s throttling: debugPrint’s default implementation is debugPrintThrottled specifically because, per its own doc comment, unthrottled rapid printing can cause data loss on Android (the platform log buffer drops lines under high-volume bursts) — debugPrintSynchronously is the same signature without throttling, for when interleaved ordering matters more than avoiding drops. dart:developer’s log() function is the other real option, distinct from both: it writes structured log records (with a name, level, error, stackTrace) that DevTools’ own logging view can filter on, rather than a flat string.

Assert stripping, proven with a real compile

Section titled “Assert stripping, proven with a real compile”

assert(...) (and any kDebugMode-gated block, since kDebugMode is itself defined in terms of the same mechanism) does not run in a release build — and the real reason is provable directly: assert execution is gated by the Dart VM/compiler’s --enable-asserts flag, which Flutter’s own tooling passes automatically for debug runs (flutter run, flutter test) and never passes for flutter build ... --release. This course’s environment can’t execute a compiled flutter build web --release output (no browser), so the identical mechanism is proven directly with plain dart compile exe — the same Dart AOT backend flutter build uses under the hood, controlled by the exact same flag:

// assert_check.dart -- plain Dart, run in scratch (not the shared probe)
void main() {
var assertsRan = false;
assert(() {
assertsRan = true;
return true;
}());
print(assertsRan ? 'ASSERTS RAN (enabled)' : 'ASSERTS DID NOT RUN (stripped)');
}
$ dart run assert_check.dart # JIT, no flag
ASSERTS DID NOT RUN (stripped)
$ dart run --enable-asserts assert_check.dart # JIT, matches `flutter run`/`flutter test` debug mode
ASSERTS RAN (enabled)
$ dart compile exe assert_check.dart -o release_build # AOT, no flag -- matches `flutter build ... --release`
$ ./release_build
ASSERTS DID NOT RUN (stripped)
$ dart compile exe --enable-asserts assert_check.dart -o release_build_forced # AOT, flag forced on
$ ./release_build_forced
ASSERTS RAN (enabled)

The fourth run is the important one: an AOT-compiled binary genuinely can run asserts if --enable-asserts is passed — proving asserts are gated by this one flag, not something structurally impossible in AOT/release code. flutter build ... --release simply never passes that flag, which is the entire, provable mechanism behind “asserts don’t run in release,” not a special separate stripping pass.

Accessibility basics: Semantics and meetsGuideline, proven

Section titled “Accessibility basics: Semantics and meetsGuideline, proven”
lib/a11y_demo.dart
import 'package:flutter/material.dart';
class UnlabeledIconButton extends StatelessWidget {
const UnlabeledIconButton({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {},
// no tooltip / no Semantics label
),
);
}
}
class LabeledIconButton extends StatelessWidget {
const LabeledIconButton({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Semantics(
label: 'Delete item',
child: IconButton(
icon: const Icon(Icons.delete),
onPressed: () {},
tooltip: 'Delete item',
),
),
);
}
}
test/a11y_demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/tooling-and-production__production-concerns/a11y_demo.dart';
void main() {
testWidgets('a labeled icon button meets the labeled-tap-target guideline',
(tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(const MaterialApp(home: LabeledIconButton()));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
handle.dispose();
});
testWidgets('an unlabeled icon button FAILS the labeled-tap-target guideline',
(tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(const MaterialApp(home: UnlabeledIconButton()));
await expectLater(
tester,
doesNotMeetGuideline(labeledTapTargetGuideline),
);
handle.dispose();
});
}
00:00 +0: a labeled icon button meets the labeled-tap-target guideline
00:00 +1: an unlabeled icon button FAILS the labeled-tap-target guideline
00:00 +2: All tests passed!

The real, captured failure message on the unlabeled button (from the negative case, run directly against meetsGuideline before flipping it to the passing doesNotMeetGuideline form used in the fence above):

Expected: Tappable widgets should have a semantic label
Actual: <Instance of 'WidgetTester'>
Which: SemanticsNode#4(Rect.fromLTRB(0.0, 0.0, 48.0, 48.0), actions: [focus, tap], flags: [isButton, hasEnabledState, isEnabled, isFocusable]): expected tappable node to have semantic label, but none was found.

meetsGuideline/doesNotMeetGuideline take any of Flutter’s built-in AccessibilityGuidelines (labeledTapTargetGuideline, textContrastGuideline, androidTapTargetGuideline, iOSTapTargetGuideline) and run a real, mechanical check against the rendered semantics tree — tester.ensureSemantics() is required first (it builds the semantics tree the test binding otherwise skips for performance, since most widget tests don’t need it) and must be .dispose()d afterward.

Performance overlay (docs — no device here)

Section titled “Performance overlay (docs — no device here)”

MaterialApp(showPerformanceOverlay: true) draws two real-time graphs directly on top of the running app: the UI thread’s per-frame time (build/layout/paint) and the raster thread’s per-frame time, each bar showing red when a frame goes over budget. It’s the fastest way to see jank while interacting with the app live, complementing DevTools’ own frame timeline (covered with its real workflow in the rebuilds-and-performance lesson). This course’s environment has no device/emulator to actually render and screenshot the overlay, so this is cited from docs.flutter.dev’s performance overlay guide rather than demonstrated.

The same --dart-define/--dart-define-from-file mechanism proven in the build-and-release lesson (a real test there confirms a defined value is genuinely read as a compile-time constant) is the standard way to inject per-environment config (API base URLs, feature flags) without committing secrets to source or branching code per environment — combined with flavors for anything that needs a genuinely different app identity (bundle ID, icon) rather than just a different config value.

AI-generated production error handling reliably reaches for the older runZonedGuarded pattern (it’s what most training data and tutorials still show) and skips the distinction this lesson proves between what each handler actually receives.

// AI-generated: "set up crash reporting"
void main() {
runZonedGuarded(() {
runApp(const MyApp());
}, (error, stack) {
reportToCrashService(error, stack);
});
}

Before trusting generated error-handling setup like this, check for:

  • No FlutterError.onError at all — proven above, a runZonedGuarded zone does not receive framework build/layout/paint errors; those need their own handler, or they only ever show as the default red error screen.
  • runZonedGuarded used as the only mechanism for async errors, with no PlatformDispatcher.instance.onError — proven above from official docs, this is the outdated pattern; the specific gap it leaves (plugin/MethodChannel async errors not forwarded to FlutterError.onError) is exactly what PlatformDispatcher.instance.onError is documented to close.
  • print/debugPrint statements left in for “production logging,” on the assumption they vanish in release — proven above from source, they do not; anything sensitive logged this way ships to real users’ device logs.
  • assert used to guard something that must actually happen in production (a real validation, not a debug-only sanity check) — proven above, assert is gated by a compile flag Flutter’s release builds never pass; code inside one simply does not run for real users.

🐛 Spot the bug

void main() {
runZonedGuarded(() {
FlutterError.onError = (details) {
reportToCrashService(details.exception, details.stack);
};
runApp(const MyApp());
}, (error, stack) {
reportToCrashService(error, stack);
});
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key, required this.userId});
final String userId;
@override
Widget build(BuildContext context) {
fetchProfile(userId); // fire-and-forget, not awaited
return const CircularProgressIndicator();
}
}
Show the answer

Two compounding issues, both proven above. First, this setup uses the outdated runZonedGuarded-only pattern for async errors instead of the currently documented PlatformDispatcher.instance.onError — a real gap the official docs name directly: an async error from a plugin call is “forwarded to the PlatformDispatcher,” not caught by this zone or by FlutterError.onError. Second, fetchProfile(userId) inside build is a fire-and-forget call (the Async in Dart lesson proves this exact pattern’s error becomes genuinely uncaught by any try/catch at the call site) — so if it throws, this whole setup is betting entirely on the single, now-superseded runZonedGuarded handler to catch it, with no PlatformDispatcher.instance.onError as a backstop for the async paths its own zone might not reach.

Every mechanism in this lesson answers the same question as the error-handling material earlier in the course, at the production layer: once an error happens, does anything downstream find out, and how much of the truth survives to get there? FlutterError.onError vs. PlatformDispatcher.instance.onError is “which layer did the error come from” (framework build vs. everything else) — proven above to be a real, disjoint split, not overlapping coverage. assert/debugPrint are both about “what survives the trip from your editor to a real user’s device” — proven above, one is fully stripped-by-flag-omission, the other genuinely is not (just throttled). And obfuscation + symbol files (from the build-and-release lesson) are the same question at the binary level: the stack trace a crash service receives is only as readable as the exact symbol file you kept alongside that specific build.

ChoiceBenefitCost
PlatformDispatcher.instance.onError (current)Proven above from official docs: catches what FlutterError.onError doesn’t, without a custom Zone’s startup costProven above: genuinely hard to unit-test directly (test tooling intercepts first) — trust the docs, verify in a real crash-reporting integration
runZonedGuarded (legacy but functional)Proven above (linked lesson): still genuinely works, easy to reason about as a plain zone handlerProven above from official docs: the exact startup-performance cost that motivated moving away from it
debugPrint over printProven above: throttled, avoiding real Android log data lossProven above: neither is release-stripped — still needs a kDebugMode/real-logger guard for anything sensitive
assert for internal invariantsZero cost in release — proven above, the flag that runs it is never passed by flutter build --releaseProven above: cannot be relied on for anything that must actually execute for real users
Semantics/meetsGuideline in testsProven above: catches a real, specific accessibility gap mechanically, in CI, with no deviceOnly covers what the built-in guidelines check — not a substitute for real screen-reader testing
  • “FlutterError.onError + runZonedGuarded is still the recommended pairing.” Disproven above by quoting Flutter’s own 3.3 release notes directly: PlatformDispatcher.instance.onError is now the documented replacement for the runZonedGuarded half, specifically for startup-performance reasons.
  • “print/debugPrint calls are automatically removed in release builds.” Disproven above by reading the installed SDK source directly: debugPrint’s own doc comment states it logs “even in release mode.”
  • “debugPrint exists mainly so logging doesn’t ship to production.” Disproven above: its own doc comment gives the real reason — throttling, to avoid real data loss on Android’s log buffer under high-volume output.
  • “Code inside assert(...) might still run in release if the compiler decides it’s cheap enough.” Disproven above with a real, forced counter-example: an AOT binary compiled with --enable-asserts genuinely does run its asserts — the mechanism is a flag Flutter’s release builds simply never pass, not a structural AOT limitation.
Proven above with a real test, what is the actual, disjoint split between FlutterError.onError and PlatformDispatcher.instance.onError?
Proven above by quoting Flutter's own 3.3 release notes directly, what is the CURRENT recommended replacement for wrapping runApp() in runZonedGuarded?
Why could this lesson not directly prove PlatformDispatcher.instance.onError firing for an uncaught async error under flutter test, and what did it do instead?
Proven above by reading the installed SDK source, what actually happens to print()/debugPrint() calls in a release build?
Proven above with a real forced counter-example, what actually gates whether assert(...) runs?
Proven above with a real meetsGuideline failure, what does an unlabeled IconButton fail, and what is required before running such a check?