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.
| Quantity | What it measures | Typical order of magnitude | Changed by |
|---|---|---|---|
| Compressed file bytes | What crosses the network and sits on disk | Hundreds of KB | Encoder, quality setting, server-side transform |
| Decoded pixel bytes | Width times height times four, in RAM | Tens of MB per photo | The decode target you request |
| Logical display size | The widget's size in logical pixels | Tens to hundreds | Layout and constraints |
| Cache accounting | What ImageCache counts against 1000 entries and 100 MB | Sum of decoded bytes | Decode target, eviction, live references |
| Raster cache | The engine's "raster cache layer(s) or picture(s)" during final rendering | Varies with painted content | Repaint 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 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:
- 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.
- Multiply by the device pixel ratio. At a ratio of 3, a 90-point tile wants 270 device pixels.
- Round up to a bucket, because the unrounded value is what creates the rotation problem below.
- 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:
allowUpscalingdefaults tofalse, 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.policydefaults toResizeImagePolicy.exact, which with both dimensions set behaves likeBoxFit.filland ignores the source aspect ratio. With one dimension set it scales to that dimension and keeps the ratio.ResizeImagePolicy.fitinstead scales to fit inside the box you give, "conceptually similar toBoxFit.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
cacheExtenton 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?

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:
- Run
flutter run --profileon a physical device and attach DevTools. - Open the Memory view and note the baseline, with Dart/Flutter Native visible as well as the heap.
- Scroll a fixed, repeatable distance. A fixed tile count beats a fixed time, because it survives a frame-rate difference between the two runs.
- Record the plateau that line reaches, not the first peak. Peaks move with eviction timing.
- Add the decode target and repeat exactly the same scroll on the same device.
- 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.
- Print
imageCache.currentSize,imageCache.currentSizeBytesandimageCache.liveImageCountat the end of each run. Those counters are the cheapest instrument in the exercise, andcurrentSizeBytesnext to the byte ceiling tells you whether you are near eviction. - 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.
- Find the largest pixel dimensions any image in the list can have. Not the file size. If nobody knows, that is the finding.
- Multiply by four and compare against the 100 MB cache ceiling. If two or three images fill it, you have the problem.
- Grep the list widgets for
cacheWidth,cacheHeight,ResizeImageandmemCacheWidth. Absence across a whole feed is the normal state of a codebase that has never profiled this. - Check whether any target present is derived from a tile constraint or hardcoded. A hardcoded
cacheWidth: 300goes stale the next time the grid gains a column. - Measure in profile mode on a device, before changing anything.
- Apply a derived, quantised target and measure again.
- Rotate and measure a third time.
- 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.
