A product list I reviewed scrolled badly on older phones. Every card showed a small thumbnail, but the app downloaded the full size photo for every item, held it in memory, and resized it on the fly.
The list was not slow because the phones were weak. It was slow because every row asked the phone to do heavy work for a 120 pixel box, and to do it again on every scroll.
The wrong fix
The wrong fix is to blame the phone or to lower the quality of every image.
Older phones are not the problem. The app is asking them to do work the server already did once, for every row, on every scroll. Lowering the quality of every image hurts the detail view too, where the user actually wants the full photo, and it still leaves the real problem in place: the list is fetching far more image than it shows.
A phone can show a small thumbnail beautifully. It struggles when you hand it a large photo, keep it in memory, shrink it down, and repeat that for a whole list.
The fix: match the image to the box it fills
Three changes, all simple on purpose.
First, ask for a thumbnail sized version. If the box is 120 by 120, the list should request an image made for that size, not the original photo. Most backends can serve a thumbnail variant, and if yours cannot yet, that is the feature to add. One thumbnail endpoint saves every list in the app.
Second, cache it on the device. A thumbnail that is downloaded once and reused on every scroll costs almost nothing the second time the user sees it. A thumbnail that is downloaded again every time the row scrolls back into view costs the same as the first time, forever.
Third, let the list build only the rows on screen. A list builder creates widgets for the visible rows plus a small buffer, not for the whole catalogue at once. That keeps memory bounded no matter how long the list is.
Put together, the list item looks like this:
CachedNetworkImage(
imageUrl: product.thumbnailUrl,
width: 120,
height: 120,
fit: BoxFit.cover,
)
Small image, cached once, reused on every scroll. The list feels fast because the phone stops doing heavy work for a 120 pixel box.
In a real list, that widget sits inside a builder, so only visible rows are built:
ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return ListTile(
leading: CachedNetworkImage(
imageUrl: product.thumbnailUrl,
width: 120,
height: 120,
fit: BoxFit.cover,
),
title: Text(product.name),
);
},
)
Note the field name in the example: thumbnailUrl, not the full size image field. That one line is the whole point. If the list requests the full size field, everything else in this article is just decoration.
A few details that keep this honest
Show something while the thumbnail loads. A plain grey box or a simple placeholder keeps the list from jumping around as images arrive. A blank row that suddenly grows when its image lands feels broken even when the code is correct.
Handle the failure case too. A thumbnail can fail to load, and a list should not show a broken image icon in its place forever. An error widget, even a simple icon, tells the user the image is missing without breaking the row.
Keep the full size image for the detail screen. This is not an argument for deleting large images. It is an argument for serving the right size in the right place. The list gets the thumbnail. The detail screen, where the user tapped to look closer, gets the full photo.
And check the image size in the network tab before you optimise anything else. If the list is requesting large images for small boxes, no amount of widget tuning will fix the scroll. If it is already requesting thumbnails and still stutters, then look at rebuilds next: a row that rebuilds on every scroll frame will stutter no matter how small its image is.
The habit behind the fix
Most slow lists I review are not slow because of one big mistake. They are slow because of a small mismatch repeated a hundred times: a full size image in a tiny box, a network call on every scroll, a row built when nobody can see it.
Match the work to the screen. Ask for the size you show, cache what you fetch, and build only what is visible. The phone was never the bottleneck. The list was just asking it to do far more than the screen needed.
What makes your lists stutter first, images, rebuilds, or something else? Drop it in the comments.
I'm Sohail Khan, a Senior Full Stack Developer building AI agents and automation. Tech Lead at LoopxCorp. I write about backend engineering, AI integration and practical system design.
Top comments (1)
The point about matching the image to its box is spot on, and on the Flutter side it's worth passing cacheWidth/cacheHeight (or memCacheWidth in cached_network_image) so the decoded bitmap is also sized to the box, not just the download. Decoded size is what eats memory, a 4000px photo is tens of MB uncompressed even if the file is small. For the backend thumbnail endpoint, we've found fixed size buckets (e.g. 120, 240, 480) cache far better than arbitrary dimensions. Did you measure memory or frame times before and after?