Skip to content
Tech Stack9 October 2026 · 15 min read

Stop Large Flutter Images From Exhausting Memory

A 300 KB photo can cost 46 MiB once it is decoded, and Flutter decodes at native size unless you say otherwise. Derive the decode target from the tile, quantise it, and measure the change on a real device.

Stop Large Flutter Images From Exhausting Memory

A photo grid loads fine for the first two screens. Then the scroll turns sticky, tiles flash grey before they appear, and on a three-year-old Android phone the app disappears without a stack trace. The files left your CDN at about 300 KB each. Nothing in that number explains what just happened.

The short version: a compressed file size tells you almost nothing about what an image costs once it is decoded, and Flutter decodes to the image's native pixel dimensions unless you say otherwise. Give every image in a scrolling list an explicit decode target derived from the size you actually display, and the same feed carries a small fraction of the memory it carried before.

My Flutter performance guide for lists, images and animations spends one subsection on this. One subsection is not enough, because the fix has a derivation, a cache interaction, and a rotation trap that will quietly hand you two copies of every photo.

Why does a feed of small-looking photos exhaust Flutter image memory?

Because the compressed file is not the thing sitting in memory. A decoded image is raw pixels, and the framework bills it as width times height times four bytes.

That is not an estimate. Flutter's ImageInfo.sizeBytes is documented as "the size of raw image pixels in bytes", and the implementation printed on that page is int get sizeBytes => image.height * image.width * 4;. Whatever your encoder achieved on disk is irrelevant by that point.

Run it on a photo from a current phone camera. A 4032 by 3024 image is 48,771,072 bytes decoded, about 46.5 MiB. The same photograph decoded to 384 by 288 is 442,368 bytes, about 432 KiB. That is a factor of 110, and it is arithmetic from the documented formula rather than anything I measured on a device.

Now put it against the cache. Flutter's ImageCache is documented as a "least-recently-used cache of up to 1000 images, and up to 100 MB". Two full-resolution photos of that size occupy most of the byte ceiling between them. A grid showing twelve tiles at once is not asking for twelve small images. It is asking for roughly half a gigabyte of pixels, and it will get as many of them as the device can stand.

Five numbers that are not the same number

Most of the confusion here comes from collapsing five separate quantities into one idea called image size. They move independently.

QuantityWhat it measuresTypical order of magnitudeChanged by
Compressed file bytesWhat crosses the network and sits on diskHundreds of KBEncoder, quality setting, server-side transform
Decoded pixel bytesWidth times height times four, in RAMTens of MB per photoThe decode target you request
Logical display sizeThe widget's size in logical pixelsTens to hundredsLayout and constraints
Cache accountingWhat ImageCache counts against 1000 entries and 100 MBSum of decoded bytesDecode target, eviction, live references
Raster cacheThe engine's "raster cache layer(s) or picture(s)" during final renderingVaries with painted contentRepaint behaviour, not decode size

Two consequences follow. Shrinking the file on the server reduces download time and disk use and does nothing for decoded memory, because the decoder still expands it to its full pixel count. And making the widget smaller does not help either: layout size and decode size are separate inputs, and Flutter will happily paint a 46 MiB bitmap into a 48-logical-pixel avatar.

That second point is worth re-reading. The displayed size is not an instruction to the decoder.

Pick the decode target from logical size and device pixel ratio

The same landscape photograph printed at three sizes on a pale workbench: a stamp-sized print and a postcard-sized print next to a poster-sized print, with a steel ruler laid diagonally across them, a pencil alongside and a strip of old tape residue on the surface

The target is the tile's logical width multiplied by the device pixel ratio, rounded up, and nothing larger. Anything beyond that is pixels the screen cannot show.

devicePixelRatio is documented as "the number of device pixels for each logical pixel for the screen this view is displayed on", with the warning that it "may not be a whole number" and "may be inaccurate". Read it through MediaQuery rather than from the view: MediaQuery.devicePixelRatioOf returns the ratio from the nearest ancestor and rebuilds your context only when that ratio changes, where MediaQuery.of rebuilds on any attribute change.

The derivation, in order:

  1. Measure the tile, not the screen. In a four-column grid 390 logical points wide, the tile is roughly 90 points across, not 390. This is the most common version of the mistake, and it leaves the decode about twenty times too large by area.
  2. Multiply by the device pixel ratio. At a ratio of 3, a 90-point tile wants 270 device pixels.
  3. Round up to a bucket, because the unrounded value is what creates the rotation problem below.
  4. Pass only the width. The decoder keeps the aspect ratio when you specify one dimension, so you rarely need both.

A 270-pixel-wide tile at a 4:3 ratio decodes to about 270 by 203, which is 219,240 bytes. Against 46.5 MiB untouched, that is the whole article in one comparison.

Decode at that size with cacheWidth, cacheHeight or ResizeImage

Image.network and Image.asset take cacheWidth and cacheHeight directly. The documentation for Image.network says they "indicate to the engine that the image should be decoded at the specified size", that "the image will be rendered to the constraints of the layout or width and height regardless of these parameters", and that they are "primarily intended to reduce the memory usage of ImageCache". Decode size and paint size are decided separately, and these parameters touch only the first.

Underneath, both constructors hand the values to ResizeImage.resizeIfNeeded. You can reach for ResizeImage yourself for any ImageProvider; it "instructs Flutter to decode the image at the specified dimensions instead of at its native size". Two of its defaults are worth knowing before you rely on it:

  • allowUpscaling defaults to false, so your requested width and height "will each be clamped to the intrinsic width and height of the image". Ask for 800 pixels from a 200-pixel source and you get 200. That is the behaviour you want in a feed, and it means a target larger than the source is harmless rather than wasteful.
  • policy defaults to ResizeImagePolicy.exact, which with both dimensions set behaves like BoxFit.fill and ignores the source aspect ratio. With one dimension set it scales to that dimension and keeps the ratio. ResizeImagePolicy.fit instead scales to fit inside the box you give, "conceptually similar to BoxFit.contain".

A grid tile that derives its own target looks like this. It is a reference implementation: there is no Flutter toolchain in the environment where I wrote this, so nothing below was compiled or run, and the checks further down are the ones you should run yourself.

/// Quantise to a short ladder so rotation and small layout shifts
/// reuse one decode instead of adding a second cache entry.
int decodeWidthFor(double logicalWidth, double devicePixelRatio) {
  final devicePixels = logicalWidth * devicePixelRatio;
  for (final bucket in const [128, 256, 384, 512, 768, 1024]) {
    if (devicePixels <= bucket) return bucket;
  }
  return 1536;
}

class FeedTile extends StatelessWidget {
  const FeedTile({super.key, required this.url});

  final String url;

  @override
  Widget build(BuildContext context) {
    final ratio = MediaQuery.devicePixelRatioOf(context);
    return LayoutBuilder(
      builder: (context, constraints) {
        return Image.network(
          url,
          fit: BoxFit.cover,
          cacheWidth: decodeWidthFor(constraints.maxWidth, ratio),
          // Surface a failed tile. Do not return an empty box here:
          // a silent gap is how a decode problem stays invisible.
          errorBuilder: (context, error, stack) => ColoredBox(
            color: Theme.of(context).colorScheme.surfaceContainerHighest,
            child: const Center(child: Icon(Icons.broken_image_outlined)),
          ),
        );
      },
    );
  }
}

LayoutBuilder is doing real work there. It reports the constraints the tile actually received, which is what you want in a grid whose column count changes with width. MediaQuery.sizeOf would give you the window instead.

If you already use cached_network_image, the equivalent knobs are documented on CachedNetworkImage: memCacheWidth and memCacheHeight "resize the image in memory to have a certain width using ResizeImage", while maxWidthDiskCache and maxHeightDiskCache "resize the image and store the resized image in the disk cache". Those are two different savings, and the memory pair is the one this article is about.

What breaks when the phone rotates?

Rotation changes the tile's logical width, which changes the computed decode target, which asks the cache for a different resource. You get a second decode and a second cache entry for the same photograph.

The reason is in the key. ResizeImage.obtainKey returns a ResizeImageKey, which Flutter describes as "used to identify the precise resource in the imageCache". My reading is that the decode dimensions are part of an image's identity in the cache, so 270 pixels wide and 284 pixels wide are two different resources with two independent allocations, however identical they look on screen.

Rotate a grid once and every visible tile can pay twice. Rotate a few times in a session, with a layout that yields slightly different tile widths each way, and the 100 MB ceiling arrives much sooner than the single-orientation arithmetic suggested.

Quantising fixes it, which is what the bucket ladder above is for. A short ladder means both orientations usually land on the same bucket and share one decode. The cost is a decode that is sometimes larger than strictly needed. Trading a little slack for cache stability is almost always right here, and it is a recommendation rather than something the documentation prescribes.

A dense grid needs a ceiling, not a bigger cache

Raising maximumSizeBytes is the reflex, and it is the wrong first move. The cache was never generating the pressure. It is the thing accounting for it.

Two documented details explain why fighting the cache goes badly. Since Flutter 1.17, "the maxByteSize of the ImageCache is no longer automatically made larger to accommodate large images", and that breaking-change note warns there "might be situations where the ImageCache is thrashing with the new logic where it wasn't previously, specifically if you load images that are larger than your cache.maxByteSize value". My reading is that an individual image bigger than the ceiling does not stay cached, so it is re-decoded every time it reappears. That would look exactly like a scroll which gets worse the longer it runs.

The ceilings are also not a hard cap on live memory. The ImageCache docs note it "also holds a list of 'live' references", where an image "is considered live if its ImageStreamCompleter's listener count has never dropped to zero after adding at least one listener". Anything currently mounted and listening is held regardless of the byte ceiling. A grid that mounts forty tiles holds forty decodes, and no cache setting changes that.

So the levers that work reduce what you mount and what you decode:

  • Set a decode target on every image in a list. Not most of them.
  • Reduce cacheExtent on long scrollables so fewer off-screen tiles are built and listening at once.
  • Page the feed instead of loading an unbounded list, and cap how many full-size detail images stay open behind the current one.
  • Fix the oversized source where you control it. FlutterFlow's Upload or Save Media action documents Max Width and Max Height properties that resize "the image while maintaining its original aspect ratio", plus an image quality slider where "100 retains the original quality". That changes the stored file, so it cuts download and disk cost too, and it does not replace a decode target for images you do not control.

Two things that look like fixes and are not. Calling imageCache.clear() under memory pressure throws away work you are about to need, and the eviction it performs does not touch live images at all, so most of the pressure you reacted to stays. And an errorBuilder that returns an empty box converts a diagnosable failure into a grey gap nobody reports. Neither is warned against on the ImageCache page. Both are my judgement after watching the symptom get misread.

How do you prove it in a profile build?

A smartphone upright in a metal stand showing a grid of photo thumbnails, a braided USB cable running from its base to the edge of an open laptop, with an open blank notebook and a pen in the foreground on a wooden desk

Measure the same scroll twice on a real device in profile mode, once without a decode target and once with it, and read the native memory line rather than the Dart heap.

The build mode matters more than people expect. Flutter's build modes page states that "application performance can be janky in debug mode" and tells you to "measure performance in profile mode on an actual device". It also notes that "profile mode is disabled on the emulator and simulator, because their behavior is not representative of real performance". A simulator number is not a smaller version of the truth here. There is no number to read at all.

Which line to watch is the other half. The DevTools memory documentation puts decoded images outside the Dart heap: Dart/Flutter Native is "memory that isn't in the Dart/Flutter heap but is still part of the total memory footprint", and the examples given for that category are native objects, "for example, from reading a file into memory, or a decoded image". A heap snapshot will not show your bitmaps. If you have been diffing snapshots and finding nothing, that is why.

The procedure:

  1. Run flutter run --profile on a physical device and attach DevTools.
  2. Open the Memory view and note the baseline, with Dart/Flutter Native visible as well as the heap.
  3. Scroll a fixed, repeatable distance. A fixed tile count beats a fixed time, because it survives a frame-rate difference between the two runs.
  4. Record the plateau that line reaches, not the first peak. Peaks move with eviction timing.
  5. Add the decode target and repeat exactly the same scroll on the same device.
  6. Rotate to landscape, scroll the same distance again, and watch whether the plateau rises by roughly one more full set of tiles. That tells you whether your target is quantised well.
  7. Print imageCache.currentSize, imageCache.currentSizeBytes and imageCache.liveImageCount at the end of each run. Those counters are the cheapest instrument in the exercise, and currentSizeBytes next to the byte ceiling tells you whether you are near eviction.
  8. Look at the tiles. Full brightness, a photo with fine detail and a flat gradient, against the original. A target derived from the real pixel ratio should be indistinguishable. If it looks soft, you rounded down somewhere, or you divided by the ratio instead of multiplying.

Record both numbers. A before-and-after pair from your own device and your own feed is worth more than any percentage I could quote, and it is the only evidence the change did anything on the hardware your users hold.

Where decode sizing does not help

Four boundaries, and the first is a hard platform limit rather than a tradeoff.

On the web, these parameters do nothing. The Image.network documentation is explicit: "in the case where the network image is on the Web platform, the cacheWidth and cacheHeight parameters are ignored as the web engine delegates image decoding to the web which does not support custom decode sizes". If your Flutter app ships to web, the decode target is a mobile and desktop optimisation only, and the web build needs correctly sized source images or server-side transforms instead.

In FlutterFlow, this is custom-code territory. The current Image widget documentation covers display Width and Height, which "specify the dimensions of the image", and a Cached toggle that "determines whether the image should be cached for performance optimization". It documents no decode-size property. The built-in action that does reduce bytes is the upload resize above, which acts on what you store rather than on how a widget decodes. A decode target for images you do not own means a custom widget or custom Dart in the project.

Vector content is unaffected, because there is no fixed pixel grid to decode. And a source image already close to its display size has nothing to give back; the 110-times saving earlier was driven entirely by the source being enormous.

One more limitation. Decode sizing reduces memory. It does not reduce what you download, and a feed of 300 KB files is still a feed of 300 KB files on a weak connection. That is a separate problem with separate tools, and it usually lives on the server.

A diagnostic checklist

Work it in this order when a feed is suspected of this problem.

  1. Find the largest pixel dimensions any image in the list can have. Not the file size. If nobody knows, that is the finding.
  2. Multiply by four and compare against the 100 MB cache ceiling. If two or three images fill it, you have the problem.
  3. Grep the list widgets for cacheWidth, cacheHeight, ResizeImage and memCacheWidth. Absence across a whole feed is the normal state of a codebase that has never profiled this.
  4. Check whether any target present is derived from a tile constraint or hardcoded. A hardcoded cacheWidth: 300 goes stale the next time the grid gains a column.
  5. Measure in profile mode on a device, before changing anything.
  6. Apply a derived, quantised target and measure again.
  7. Rotate and measure a third time.
  8. Inspect visible quality before you ship it.

Start with one grid

Pick the worst screen in your app. Usually that is the densest grid, or the one where support tickets mention crashing on older phones. Instrument that single screen in profile mode, write down the plateau, add a derived decode target to its tiles, and write down the plateau again.

Decoding and resizing many images at once is also work that does not belong on the UI thread, and moving heavy work to isolates and background tasks is the tool for that. The wider architectural picture for a photo-heavy product sits in my notes on building a SaaS mobile app with Flutter and Firebase.

What is the largest image your feed can currently be handed? If you cannot answer that in a minute, that is the first thing to fix.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

Does cacheWidth change how big the image looks on screen?

No. The decode size and the paint size are separate. Flutter's Image.network documentation says the image "will be rendered to the constraints of the layout or width and height regardless of these parameters", and that cacheWidth and cacheHeight are "primarily intended to reduce the memory usage of ImageCache". So a tile with a decode target of 270 pixels still fills whatever box your layout gives it. What changes is the number of pixels held in memory while it does. If the tile looks different after you add a target, you set the target below the displayed size times the device pixel ratio, and you are seeing upscaling of a smaller bitmap.

Why does my heap snapshot show nothing after I load fifty photos?

Because decoded images are not on the Dart heap. The DevTools memory documentation puts them under Dart/Flutter Native, which it defines as "memory that isn't in the Dart/Flutter heap but is still part of the total memory footprint", and the examples it gives for that category are native objects, "for example, from reading a file into memory, or a decoded image". Snapshot diffing is the right tool for a Dart object leak and the wrong tool for this. Watch the Dart/Flutter Native line on the chart instead, and print imageCache.currentSizeBytes at the start and end of the scroll for a second reading.

Should I just raise ImageCache.maximumSizeBytes?

Not as a first move, because it treats the accounting as the problem. The cache is documented as holding "up to 1000 images, and up to 100 MB", and raising that ceiling lets more oversized decodes accumulate rather than preventing any of them. There is one narrow case where it helps: the Flutter 1.17 breaking-change note lists raising maximumSizeBytes as one remedy for thrashing when individual images exceed the ceiling. Even there, its other suggested remedy, adjusting your loading logic so images "fit nicely" inside the ceiling, is the one that also fixes the memory.

Does any of this work on Flutter web?

No, and the documentation says so directly. Image.network states that "in the case where the network image is on the Web platform, the cacheWidth and cacheHeight parameters are ignored as the web engine delegates image decoding to the web which does not support custom decode sizes". A web build therefore needs the work done before the bytes arrive: correctly sized sources, a server-side image transform, or responsive variants chosen by the client. Shared code that ships to both mobile and web should still set a decode target, because it is a genuine saving on the mobile targets and a harmless no-op on web.

How do I do this in FlutterFlow without writing Dart?

Mostly you cannot, and it is worth knowing where the line is. The current Image widget documentation covers display Width and Height and a Cached toggle for network images, and documents no decode-size property. What is built in is upstream: the Upload or Save Media action has Max Width and Max Height that resize "the image while maintaining its original aspect ratio", plus an image quality slider. That caps what your own users upload, which solves the common case. Images from an API you do not control still need a custom widget or custom Dart to carry a decode target.