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”import 'package:flutter/material.dart';
class ThrowsInBuild extends StatelessWidget { const ThrowsInBuild({super.key}); @override Widget build(BuildContext context) { throw StateError('build error'); }}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.
Crash reporting and logging
Section titled “Crash reporting and logging”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
--obfuscateis used, identifiers are renamed too; without the exact matching symbol file, stack traces are unreadable (proven in full, with a real--split-debug-infobuild and real symbol files, in the build-and-release lesson). - Log deliberately, and know what actually survives release — this course’s earlier version claimed
printis “stripped/ignored in release.” Reading the installed SDK source directly (~/flutter/packages/flutter/lib/src/foundation/print.dart) disproves that: neitherprintnordebugPrintis stripped in release.debugPrint’s own doc comment is explicit: “ThedebugPrintfunction logs to console even in release mode. As per convention, calls todebugPrintshould be within a debug mode check or an assert.” The real reason to preferdebugPrintover bareprintisn’t release-stripping — it’s throttling:debugPrint’s default implementation isdebugPrintThrottledspecifically 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) —debugPrintSynchronouslyis the same signature without throttling, for when interleaved ordering matters more than avoiding drops.dart:developer’slog()function is the other real option, distinct from both: it writes structured log records (with aname,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 flagASSERTS DID NOT RUN (stripped)
$ dart run --enable-asserts assert_check.dart # JIT, matches `flutter run`/`flutter test` debug modeASSERTS RAN (enabled)
$ dart compile exe assert_check.dart -o release_build # AOT, no flag -- matches `flutter build ... --release`$ ./release_buildASSERTS DID NOT RUN (stripped)
$ dart compile exe --enable-asserts assert_check.dart -o release_build_forced # AOT, flag forced on$ ./release_build_forcedASSERTS 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”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', ), ), ); }}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 guideline00: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.
Environment config
Section titled “Environment config”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.
Reading AI-generated code
Section titled “Reading AI-generated code”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.onErrorat all — proven above, arunZonedGuardedzone does not receive framework build/layout/paint errors; those need their own handler, or they only ever show as the default red error screen. runZonedGuardedused as the only mechanism for async errors, with noPlatformDispatcher.instance.onError— proven above from official docs, this is the outdated pattern; the specific gap it leaves (plugin/MethodChannelasync errors not forwarded toFlutterError.onError) is exactly whatPlatformDispatcher.instance.onErroris documented to close.print/debugPrintstatements 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.assertused 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;
@overrideWidget 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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
PlatformDispatcher.instance.onError (current) | Proven above from official docs: catches what FlutterError.onError doesn’t, without a custom Zone’s startup cost | Proven 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 handler | Proven above from official docs: the exact startup-performance cost that motivated moving away from it |
debugPrint over print | Proven above: throttled, avoiding real Android log data loss | Proven above: neither is release-stripped — still needs a kDebugMode/real-logger guard for anything sensitive |
assert for internal invariants | Zero cost in release — proven above, the flag that runs it is never passed by flutter build --release | Proven above: cannot be relied on for anything that must actually execute for real users |
Semantics/meetsGuideline in tests | Proven above: catches a real, specific accessibility gap mechanically, in CI, with no device | Only covers what the built-in guidelines check — not a substitute for real screen-reader testing |
Common Misconceptions
Section titled “Common Misconceptions”- “
FlutterError.onError+runZonedGuardedis still the recommended pairing.” Disproven above by quoting Flutter’s own 3.3 release notes directly:PlatformDispatcher.instance.onErroris now the documented replacement for therunZonedGuardedhalf, specifically for startup-performance reasons. - “
print/debugPrintcalls 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.” - “
debugPrintexists 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-assertsgenuinely does run its asserts — the mechanism is a flag Flutter’s release builds simply never pass, not a structural AOT limitation.