skip to content

In Flutter, how do an Image's loadingBuilder, frameBuilder and errorBuilder differ, and how would you use them for a listing photo?

level: middleimportance: should knowfreq 44%

answer

  1. loadingProgress null means done
  2. expectedTotalBytes may be null
  3. frame null until the first frame
  4. wasSynchronouslyLoaded skips the fade
  5. errorBuilder or FlutterError.onError

basics

~20 s

loadingBuilder reports download progress and is called with null once loading completes; frameBuilder learns when the first frame is ready, for placeholders and fade-ins; errorBuilder replaces the image when loading fails. When both are set, loadingBuilder's child is frameBuilder's result.

solid answer

~40 s

`loadingBuilder(context, child, loadingProgress)` receives an `ImageChunkEvent` while bytes arrive, with `cumulativeBytesLoaded` and a nullable `expectedTotalBytes`, and `null` once the image is complete; the `Image` rebuilds on every frame while it loads, so it is for real progress indicators. `frameBuilder(context, child, frame, wasSynchronouslyLoaded)` gets `frame == null` until the first frame is available and a flag telling whether it came synchronously from the cache, which is how a fade-in skips cached images. `errorBuilder(context, error, stackTrace)` returns a replacement widget; without one, the error goes to `FlutterError.onError`. For a listing photo I use `frameBuilder` for a fade-in, `loadingBuilder` only on the full-screen viewer, and `errorBuilder` for a "photo unavailable" tile.

code

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

Widget listingThumbnail(String url) {
  return Image.network(
    url,
    fit: BoxFit.cover,
    frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
      if (wasSynchronouslyLoaded) return child;
      return AnimatedOpacity(
        opacity: frame == null ? 0 : 1,
        duration: const Duration(milliseconds: 250),
        child: child,
      );
    },
    errorBuilder: (context, error, stackTrace) => const ColoredBox(
      color: Colors.black12,
      child: Center(child: Text('Photo unavailable')),
    ),
  );
}

go deeper

for a junior

Know which builder shows progress, which shows a fallback on error, and that loadingProgress is null once the image has loaded.

for a middle

Explain frame and wasSynchronouslyLoaded, nullable expectedTotalBytes, how the builders chain, and where errors go without an errorBuilder.

for a senior

Pick builders per screen for cost and feel: no per-frame rebuilds in grids, no fade on cached images, and retries that really refetch.

for a principal

Define shared image widgets so loading and failure states look and behave the same across every photo surface in the app.

## Three hooks, three moments An `Image` goes through three observable moments: bytes arriving, the first frame becoming available, and possibly failure. Each has its own builder: | Builder | Signature | Called with | |---|---|---| | `loadingBuilder` | `(context, child, ImageChunkEvent? loadingProgress)` | progress events while loading, then `null` when done | | `frameBuilder` | `(context, child, int? frame, bool wasSynchronouslyLoaded)` | `frame == null` before the first frame, then 0, 1, 2 for animated images | | `errorBuilder` | `(context, Object error, StackTrace? stackTrace)` | the failure, once | For the first two, `child` is the widget that paints the image, so the builder wraps it, replaces it, or returns it unchanged; `errorBuilder` has no `child` and returns the replacement outright. ## loadingBuilder: real progress `ImageChunkEvent` carries: - `cumulativeBytesLoaded`, the bytes received so far; - `expectedTotalBytes`, **nullable**, because the server may not send a content length. A determinate progress bar uses `cumulativeBytesLoaded / expectedTotalBytes!` only when the total is known and falls back to an indeterminate indicator otherwise. When loading finishes, the builder is called with **`null`**, and it should then return `child`. Performance matters here: with a `loadingBuilder`, the framework documents that the `Image` is likely to rebuild on **every frame** until the image has loaded. For a grid of forty listing thumbnails, that is forty widgets rebuilding each frame during loading. Use it where progress is worth showing, such as a full-screen photo viewer on a slow connection. ## frameBuilder: placeholders and fade-ins `frameBuilder` is cheaper: the `Image` subscribes to download-progress events only when a `loadingBuilder` is set, so a `frameBuilder` alone does not rebuild for every chunk of bytes. Two arguments do the work: 1. `frame` is `null` until the first image frame is available, so a builder can show a neutral placeholder until then. 2. `wasSynchronouslyLoaded` is `true` when the image was already in the `ImageCache` and appeared in the same frame. Animating a fade-in in that case makes cached photos flicker, so the usual pattern returns `child` directly when it is true. ## errorBuilder: failure without a red box When the network fails, the server returns 404 or the file cannot be decoded, `errorBuilder` returns a replacement, for example a tile reading "Photo unavailable" with a retry button. Without it, the exception is reported to **`FlutterError.onError`**, which in debug builds prints a large error. `NetworkImage` evicts a failed key from the cache, so rebuilding the image after a retry really goes back to the network. ## Retrying after an error A "Tap to retry" tile inside `errorBuilder` needs the image to resolve again. Rebuilding the same `Image` with an equal provider does **not** re-resolve, because the widget only resolves when its provider changes or its dependencies change. Two ways work: 1. Remount the image: keep a retry counter in `State` and give the `Image` a `ValueKey(attempt)`, so a fresh `State` resolves from scratch. 2. Change the provider, for example by adding a harmless query parameter, at the cost of a new cache key. Because `NetworkImage` evicted the failed key, the remounted image goes back to the network instead of replaying the cached failure. ## Chaining When both `frameBuilder` and `loadingBuilder` are set, they are chained: the `child` passed to `loadingBuilder` is the **result of `frameBuilder`**. A fade-in from `frameBuilder` can therefore sit inside a progress overlay from `loadingBuilder`. ## Alternatives - **`FadeInImage`** (for example `FadeInImage.assetNetwork`) cross-fades from a placeholder image to the target and covers the most common case in one widget. - A plain `Stack` with a placeholder behind the image works when the image's own background can stay transparent while loading. ## Choosing for a real-estate app 1. **Grid thumbnails**: `frameBuilder` fade-in plus `errorBuilder`; no `loadingBuilder`. 2. **Full-screen viewer**: `loadingBuilder` with determinate progress when `expectedTotalBytes` is known, plus `errorBuilder` with retry. 3. **Agent avatars from the bundle**: none; assets load almost instantly and rarely fail.

  • Why does a fade-in written with frameBuilder flicker for cached photos unless it checks wasSynchronouslyLoaded?
    A cached image is available in the very first frame, so `wasSynchronouslyLoaded` is true. If the builder still starts at opacity 0 and animates to 1, a photo the user already saw fades in again every time the tile rebuilds or scrolls back into view. Returning `child` directly in that case shows cached photos instantly.
  • Why prefer frameBuilder over loadingBuilder for a grid of thumbnails?
    With a `loadingBuilder`, each `Image` listens to progress events and is likely to rebuild on every frame until it has loaded, which multiplies across a grid. Without one, the `Image` ignores progress chunks, so a `frameBuilder` can show a placeholder and a fade-in without those rebuilds. Keep `loadingBuilder` for places where progress really matters.

saying these in an interview costs you the question

  • loadingBuilder is called only once, when loading starts.
  • expectedTotalBytes is always known for network images.
  • Without errorBuilder, a failed image silently shows nothing and logs nothing.
  • Adding only a frameBuilder makes the Image rebuild for every chunk of downloaded bytes.
  • When both builders are set, only loadingBuilder is used.