skip to content

Users of a Flutter release build see a grey box where a crashed widget should be, yet the crash reporter shows nothing; what is missing, and how do you fix it?

level: seniorimportance: should knowfreq 38%

answer

  1. the grey box was caught, not uncaught
  2. reportError runs before ErrorWidget.builder
  3. default onError only prints
  4. last assignment wins; chain the previous
  5. context names the widget that failed

basics

~20 s

The grey box means the framework caught a build exception and sent it to FlutterError.onError, whose default only prints. Reporting wired only to uncaught errors never sees it; forward FlutterError.onError to the reporter, then fix the build.

solid answer

~40 s

A grey box in release is the default `ErrorWidget`: a widget's `build` threw, the element caught it, called `FlutterError.reportError`, then swapped in `ErrorWidget.builder(details)`. Because the framework caught it, the error never reaches `PlatformDispatcher.instance.onError` or a zone guard. It went to `FlutterError.onError`, which by default only prints. So check `FlutterError.onError` first. It may never have been set, a later `FlutterError.onError = ...` in some module may have replaced the reporter's handler, or the handler may drop errors. Set it once in `main()` before `runApp`, call `presentError`, and forward `exception`, `stack`, `library` and the `context` text. Chain any previous handler. Then use the report's context ("building ProductCard") to find the throw, usually release-only data hitting a `!` or a cast. Finally, give release users a cheap `ErrorWidget.builder` fallback.

code

dart · 19 lines
dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void installFrameworkErrorForwarding(
  void Function(Object error, StackTrace? stack, Map<String, String> tags) send,
) {
  final FlutterExceptionHandler? previous = FlutterError.onError;
  FlutterError.onError = (FlutterErrorDetails details) {
    FlutterError.presentError(details);
    send(details.exception, details.stack, <String, String>{
      'library': details.library ?? 'unknown',
      'context': details.context?.toString() ?? '',
      'silent': '${details.silent}',
    });
    if (previous != null && previous != FlutterError.presentError) {
      previous(details);
    }
  };
}

go deeper

for a junior

Recognise the grey box as the release form of the red error screen, which means a widget's build threw.

for a middle

Explain that the framework caught this error, so only FlutterError.onError saw it, and why the default handler only prints.

for a senior

Audit the hook wiring (unset, overwritten, filtered or not yet ready), fix it with chaining, and use the forwarded context to find the data bug.

for a principal

Define how caught and uncaught errors are counted and alerted per release, and who owns the global error hooks in a multi-team app.

## Reading the symptom A **grey box** where a widget should be is the release (and profile) look of the default **`ErrorWidget`**. It proves three things: 1. A widget-building callback threw: a `build` method, a `LayoutBuilder` builder, or a list item builder. 2. The framework **caught** the exception. Nothing crashed, and the rest of the screen still works. 3. Before swapping in the replacement, the element called `FlutterError.reportError(details)`, so **`FlutterError.onError` ran with the full details**. "The reporter shows nothing" therefore means the problem is on the `FlutterError.onError` side, not somewhere deep in the app. ## Why the reporter never saw it | Cause | How it happens | How to spot it | |---|---|---| | hook never set | only `PlatformDispatcher.instance.onError` or a zone guard was wired; both see **uncaught** errors, and this one was caught | `FlutterError.onError` is still the default presenter | | hook overwritten | a later `FlutterError.onError = ...` in a feature module or debug tool replaced the reporter's handler; it is one static field, and the last assignment wins | search the codebase for assignments | | hook filters it out | the handler drops errors by `library`, by `silent`, or by a sampling rule | read the handler | | reporter not ready | errors thrown before the reporter finished initialising are lost | the order of work in `main()` | The device log (`flutter logs`, or the platform log viewer) usually still has the default dump, because `presentError` prints outside debug too, just without the diagnostics tree. ## Fixing the wiring 1. In `main()`, before `runApp`, initialise the reporter and then set `FlutterError.onError`. 2. In the handler, call `FlutterError.presentError(details)` first to keep console and IDE output. 3. Forward `details.exception` and `details.stack`, plus `details.library` and `details.context?.toString()`. In release, `informationCollector` adds less, because the framework attaches the failing element's debug creator only in debug mode, so the context text matters. 4. If another handler might already be installed, **chain it** instead of replacing it: keep `final previous = FlutterError.onError;` and call `previous?.call(details)`. 5. Keep `PlatformDispatcher.instance.onError` for the uncaught class, returning `true` once the error is recorded. 6. Add a check to code review: exactly one place assigns these global hooks. ## Finding the real bug - The report's **context** reads like "building ProductCard(...)", which names the widget whose build threw. - The **exception type** usually points at data: a `!` on a null the test data never had, a `RangeError` on an empty list, a failed `as` cast on a JSON field. - Check what changed in the **data**, not only in the code. A release that passed every test can still meet a server response, a locale or a stored value the tests never used, and the grey box appears only for the users who have it. - Group reports by the context text. One widget failing for many users is a data or contract problem; many widgets failing at once usually points to something shared, such as a theme or a localisation lookup. - Reproduce with the same data in **profile mode** if you need release-like behaviour with a readable stack, or in debug to get the red screen and the full diagnostics tree. - If release stack traces are obfuscated, symbolicate them with the symbols saved at build time. That process belongs to the release build set-up. ## Improving what users see The default grey box is honest but ugly. Assign **`ErrorWidget.builder`** in release to a cheap fallback that fits the design, such as a neutral panel saying the section could not load. The API doc stresses doing as little work as possible there: it runs right after an exception thrown mid-build, when the surrounding tree and the `BuildOwner` may be fragile. So use no providers, no network calls and no animation. Keep the default in debug so developers still see the message. ## What to monitor afterwards - The count of framework-caught errors per release, separate from uncaught ones. The two hooks make this easy, since each can tag its source. - Grey boxes are **non-fatal** from the process's point of view, but they are real user-visible failures, so do not filter them out as noise. - `silent` errors, such as image loading failures, are a separate choice. Forward them at a lower severity or drop them, but decide on purpose.

  • Why would a reporter wired only through a zone guard or PlatformDispatcher.instance.onError miss this error?
    Both see errors nobody caught. A build exception is caught by the element's rebuild code, reported through `FlutterError.reportError`, and replaced with an `ErrorWidget`, so it never becomes an uncaught error. Only `FlutterError.onError` sees it.
  • Why does the report from a release build carry less context than the same error in debug?
    In debug the framework attaches the failing element's debug creator to the details, and `dumpErrorToConsole` renders a full diagnostics tree. In release the information collector adds less and the console gets only the label and stack. Forwarding `library` and the `context` text is what still tells you which widget was building.
  • What should a custom ErrorWidget.builder avoid?
    Anything that can fail or do real work: reading providers or inherited state that may be mid-rebuild, starting network calls, or animating. It runs right after an exception inside build, so the API doc recommends the simplest leaf-like widget that tolerates any constraints.

saying these in an interview costs you the question

  • A grey box means the app crashed and the reporter should have a fatal crash
  • A zone guard alone catches build exceptions before the framework does
  • Assigning FlutterError.onError twice runs both handlers
  • Release builds skip FlutterError.onError to save work
  • A rich ErrorWidget.builder that reads app state is a safe fallback