skip to content

In Flutter, how does an ImageProvider's cache key decide whether two images share one decoded copy, and where do keys go wrong?

level: seniorimportance: nice to knowfreq 22%

answer

  1. obtainKey, then ImageCache lookup
  2. NetworkImage: url, scale, headers
  3. FileImage: path, not contents
  4. MemoryImage: the same Uint8List instance
  5. provider.evict() to refresh

basics

~20 s

ImageCache reuses a decoded image whenever two providers produce equal keys from obtainKey. NetworkImage compares URL, scale and headers; FileImage only the path; MemoryImage the exact Uint8List instance, which is where most key bugs start.

solid answer

~30 s

Resolution calls `obtainKey(configuration)` and then `ImageCache.putIfAbsent(key, ...)`, so equal keys share one decoded image and one load. The keys differ per provider. `NetworkImage` equality compares `url`, `scale` and `headers`, so signed URLs whose query string changes every request never hit the cache. `FileImage` compares `file.path` and `scale`, so overwriting a photo at the same path keeps showing the stale decode until you call `FileImage(file).evict()`. `MemoryImage` compares the `Uint8List` by identity, so `Image.memory(base64Decode(s))` inside `build()` creates a new key on every rebuild, decoding again and filling the cache. `ResizeImage` adds the target size. `provider.evict()` computes the key and removes it.

code

dart · 23 lines
dart
import 'dart:convert';
import 'dart:typed_data';

import 'package:flutter/material.dart';

class ListingSummary {
  ListingSummary({required this.id, required String thumbnailBase64})
      : thumbnail = MemoryImage(base64Decode(thumbnailBase64));

  final String id;
  final ImageProvider thumbnail;
}

class ListingTile extends StatelessWidget {
  const ListingTile({super.key, required this.listing});

  final ListingSummary listing;

  @override
  Widget build(BuildContext context) {
    return Image(image: listing.thumbnail, width: 96, height: 72, fit: BoxFit.cover);
  }
}

go deeper

for a junior

Know that images with the same provider settings share one decoded copy through the ImageCache.

for a middle

Explain obtainKey and putIfAbsent, and what NetworkImage, FileImage, MemoryImage and ResizeImage keys compare.

for a senior

Diagnose duplicate downloads, stale files and flicker from unstable keys, and fix them with stable providers, eviction or a custom key.

for a principal

Agree an image-identity scheme between backend and clients, stable IDs rather than volatile URLs, so caching works at every layer.

## How resolution uses the key When an `Image` needs its picture, its provider runs roughly this sequence: 1. `resolve(configuration)` is called with an `ImageConfiguration` (device pixel ratio, bundle, locale, platform). 2. The provider's **`obtainKey(configuration)`** produces a key object describing exactly which image is meant. 3. `ImageCache.putIfAbsent(key, loader)` returns the existing stream for an **equal** key, or calls `loadImage(key, decode)` to start a new one. So two widgets share a decoded image, and a single network request, exactly when their keys are equal by `==`. Getting the key right is the difference between one decode and dozens. ## What each built-in key compares | Provider | Key equal when | Consequence | |---|---|---| | `NetworkImage` | same `url`, `scale` and `headers` | a changing query string or header means a new entry | | `FileImage` | same `file.path` and `scale` | new contents at the same path are **not** noticed | | `MemoryImage` | the **same `Uint8List` object** and `scale` | equal bytes in a new list are a different key | | `AssetImage` | same bundle and chosen asset variant | a variant choice can differ by device pixel ratio | | `ResizeImage` | wrapped key plus width, height, policy, upscaling | each decode size is its own entry | ## Where keys go wrong in a real-estate app **Signed or cache-busting URLs.** Photo URLs that carry a fresh signature or timestamp on every API call look like new images every time. Each one is downloaded and decoded again, and the old entries sit in the cache until evicted. Keep URLs stable for the lifetime of a photo, or build a custom provider whose key ignores the volatile part. **Photos replaced on disk.** An agent re-shoots the front of a house and the app saves it to the same file path. `FileImage` compares paths only, so every `Image.file` keeps showing the cached old photo. After writing the file, call `FileImage(file).evict()`, or save to a new path. **Bytes decoded in build.** A listing API returns thumbnails as base64 strings. Writing `Image.memory(base64Decode(listing.thumb))` in `build()` creates a new `Uint8List` on every rebuild; each is a new `MemoryImage` key, so the image is decoded again, flickers, and the cache fills with duplicates. Decode once and keep the `Uint8List` in the model, or keep one `MemoryImage` per listing. **Different decode sizes.** A thumbnail with `cacheWidth: 360` and a viewer with `cacheWidth: 1080` are intentionally separate entries. Precaching one does not help the other. ## Evicting deliberately `ImageProvider.evict({cache, configuration})` computes the provider's key and removes it from the cache, returning whether anything was removed. Use it when you know the source changed. Two cautions: - For providers whose key depends on the configuration, such as `AssetImage`, pass the same configuration the widget used, or a different key is evicted. - Eviction removes the cache entry; an `Image` still displaying the old picture keeps it until it resolves again, and a rebuild with an **equal** provider does not trigger that. Remount the widget, for example with a new `ValueKey`, or give it a different provider. ## Inspecting the cache When a key problem is suspected, ask the cache directly: - `imageCache.statusForKey(key)` returns an `ImageCacheStatus` whose `pending`, `keepAlive` and `live` flags say how the key is tracked. - `provider.obtainCacheStatus(configuration: ...)` does the same starting from a provider, computing its key first. - `imageCache.containsKey(key)` is the quick yes or no. - `imageCache.currentSize` and `currentSizeBytes` growing steadily while the user flips between the same few listings is the classic sign of unstable keys. ## Writing a custom provider A custom provider extends `ImageProvider<T>`, implements `obtainKey` and `loadImage(key, decode)`, and must give its key a correct `==` and `hashCode`. The older `loadBuffer` hook has been deprecated since Flutter 3.7 in favour of `loadImage`. A common reason to write one is a signed-URL provider whose key is the photo's stable identifier while `loadImage` fetches with a fresh signature. ## Checklist 1. Keep one provider object per photo where possible and reuse it. 2. Keep keys stable: identifiers, not timestamps. 3. Evict when you overwrite a source. 4. Never create byte lists or providers inside `build()`.

  • After saving a re-shot photo over the same file, why does Image.file still show the old picture, and how do you fix it?
    `FileImage` keys compare only the file path and scale, so the cache returns the image decoded from the old contents. Call `FileImage(file).evict()` after writing, and remount the `Image`, for example with a new `ValueKey`, because rebuilding it with an equal `FileImage` does not re-resolve. Writing each version to a new path avoids both steps.
  • Why does a listing photo with a freshly signed URL get downloaded on every visit?
    `NetworkImage` equality includes the full URL string, so a new signature produces a new key and a cache miss each time. Keep the URL stable for the photo's lifetime, or write a custom provider whose key uses the photo's stable ID while its `loadImage` fetches with the current signature.

saying these in an interview costs you the question

  • MemoryImage compares bytes by content, so equal thumbnails share a cache entry.
  • FileImage notices when the file's contents change.
  • Two NetworkImages with the same URL but different headers share a cache entry.
  • evict() also clears the picture an Image is currently showing.
  • Custom providers should implement loadBuffer as the main hook.