How to Handle Image Caching in Flutter Without Burning Through Mobile Data
Open your Flutter app on a slow network. Scroll through a list of products. The images load. Now scroll up. The same images load again — from the network. Blank placeholders flash. The shimmer effect plays. The user waits for images they already saw two seconds ago.
This happens in more Flutter apps than you'd think, because Image.network() has no persistent cache by default. It uses an in-memory cache that evicts aggressively and vanishes entirely when the app restarts. Every cold start, every rebuild, every navigation — your users re-download the same 200KB product photos over and over.
On WiFi, nobody notices. On a 3G connection in a country where mobile data costs real money, your users notice — and they uninstall.
Why Image.network Is Not Enough
// ❌ No disk cache, no placeholder, no error handling
Image.network('https://api.example.com/images/product-123.jpg')Here's what Image.network actually does:
- Checks Flutter's in-memory
ImageCache(default: 100 images or 100MB) - If not cached, downloads the full image over the network
- Decodes and renders it
- Stores it in memory (not on disk)
Problems:
- Memory-only cache — Cleared when the app is killed or memory is low
- No disk persistence — The same image is re-downloaded across app sessions
- No placeholder — Users see nothing until the image fully downloads
- No error handling — Broken URLs show a red error icon or blank space
- No size limits — Loading a 4000x3000 photo for a 100px thumbnail wastes bandwidth and RAM
Common Mistakes
Mistake 1: Using precacheImage and Thinking You're Done
// ❌ This only pre-loads into memory — not disk
@override
void didChangeDependencies() {
super.didChangeDependencies();
precacheImage(NetworkImage(imageUrl), context);
}precacheImage downloads the image ahead of time, but it goes into the same volatile in-memory cache. It helps with perceived performance on the current session but doesn't solve disk caching.
Mistake 2: Manually Saving Bytes to Disk
// ❌ Don't reinvent the wheel
final response = await http.get(Uri.parse(imageUrl));
final file = File('${appDir.path}/cached_${imageUrl.hashCode}.jpg');
await file.writeAsBytes(response.bodyBytes);You've just created a custom cache with no eviction policy, no size limits, no thread safety, and no cache-control header support. It'll grow until the user's storage is full and you'll get 1-star reviews.
Mistake 3: Not Resizing Images
// ❌ Loading a 4MB photo for a 48px avatar
CircleAvatar(
backgroundImage: NetworkImage(user.photoUrl), // Full resolution
radius: 24,
)If your server sends a 2000x2000 image and you display it at 48x48, you've wasted 99% of the download and memory. Always request the size you need, or resize on the client.
The Right Solution: cached_network_image + Proper Configuration
cached_network_image is the standard. It uses flutter_cache_manager under the hood, which handles disk caching, cache-control headers, eviction, and concurrent download deduplication.
Step 1: Add the Dependency
dependencies:
cached_network_image: ^3.3.1
flutter_cache_manager: ^3.3.2Step 2: Basic Usage
CachedNetworkImage(
imageUrl: 'https://api.example.com/images/product-123.jpg',
placeholder: (context, url) => const ShimmerPlaceholder(),
errorWidget: (context, url, error) => const Icon(
Icons.broken_image_outlined,
color: Colors.grey,
size: 48,
),
fadeInDuration: const Duration(milliseconds: 200),
fadeOutDuration: const Duration(milliseconds: 200),
)What this gives you out of the box:
- Disk cache — Images survive app restarts
- Memory cache — Instant display for recently viewed images
- Placeholder — Shows while downloading
- Error widget — Graceful fallback for broken URLs
- Fade animation — Smooth transition from placeholder to image
- Deduplication — Multiple widgets requesting the same URL share one download
Step 3: Configure Cache Limits
The default cache manager has sensible defaults, but you should tune them for your app:
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
class AppCacheManager extends CacheManager {
static const key = 'appImageCache';
static final AppCacheManager _instance = AppCacheManager._();
factory AppCacheManager() => _instance;
AppCacheManager._()
: super(Config(
key,
stalePeriod: const Duration(days: 14), // Re-download after 14 days
maxNrOfCacheObjects: 500, // Keep max 500 images
repo: JsonCacheInfoRepository(databaseName: key),
fileService: HttpFileService(),
));
}Then use it:
CachedNetworkImage(
imageUrl: product.imageUrl,
cacheManager: AppCacheManager(),
// ...
)Rule of thumb for limits:
- E-commerce app (hundreds of product images):
maxNrOfCacheObjects: 500,stalePeriod: 7 days - Social media (feed of user photos):
maxNrOfCacheObjects: 300,stalePeriod: 3 days - Profile avatars (rarely change):
maxNrOfCacheObjects: 200,stalePeriod: 30 days - Chat app (lots of shared images):
maxNrOfCacheObjects: 1000,stalePeriod: 14 days
Step 4: Resize Images Properly
If your server supports image resizing (most CDNs do), request the exact size you need:
/// Builds a URL with size parameters for your image CDN
String getOptimizedUrl(String originalUrl, {required int width}) {
// Cloudinary example
return originalUrl.replaceFirst(
'/upload/',
'/upload/w_$width,f_auto,q_auto/',
);
// Or for imgix:
// return '$originalUrl?w=$width&auto=format,compress';
}
// Usage
CachedNetworkImage(
imageUrl: getOptimizedUrl(product.imageUrl, width: 300),
// 300px wide instead of the original 2000px
)If your server doesn't support resizing, constrain it on the client:
CachedNetworkImage(
imageUrl: product.imageUrl,
memCacheWidth: 300, // Decode at this width in memory
maxWidthDiskCache: 600, // Store at this max width on disk
)memCacheWidth is especially important — it tells Flutter to decode the image at a smaller resolution, saving RAM. A 2000x2000 JPEG takes ~16MB of decoded memory. At 300x300, it takes ~360KB. That's a 44x difference.
Step 5: Build a Reusable Image Widget
Don't repeat caching configuration everywhere. Build one widget your whole app uses:
class AppImage extends StatelessWidget {
final String url;
final double? width;
final double? height;
final BoxFit fit;
final BorderRadius? borderRadius;
const AppImage({
required this.url,
this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius,
super.key,
});
@override
Widget build(BuildContext context) {
final memWidth = width != null ? (width! * 2).toInt() : null; // 2x for retina
Widget image = CachedNetworkImage(
imageUrl: url,
width: width,
height: height,
fit: fit,
memCacheWidth: memWidth,
cacheManager: AppCacheManager(),
placeholder: (_, __) => Container(
width: width,
height: height,
color: Colors.grey.shade900,
child: const Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
errorWidget: (_, __, ___) => Container(
width: width,
height: height,
color: Colors.grey.shade900,
child: const Icon(Icons.broken_image_outlined, color: Colors.grey),
),
fadeInDuration: const Duration(milliseconds: 150),
);
if (borderRadius != null) {
image = ClipRRect(borderRadius: borderRadius!, child: image);
}
return image;
}
}
// Usage — clean and consistent everywhere
AppImage(
url: product.imageUrl,
width: 120,
height: 120,
borderRadius: BorderRadius.circular(12),
)Step 6: Cache Invalidation
When data changes (user updates their profile photo), you need to bust the cache for that specific URL:
// Remove a specific image from cache
await AppCacheManager().removeFile(oldImageUrl);
// Nuclear option — clear everything
await AppCacheManager().emptyCache();A smarter pattern: use versioned URLs. When the user updates their avatar, the server returns a new URL (or adds a version query parameter):
// Server returns a new URL each time the image changes
// Old: https://cdn.example.com/avatars/user-123-v1.jpg
// New: https://cdn.example.com/avatars/user-123-v2.jpg
// Or use a cache-busting query param
'${user.avatarUrl}?v=${user.updatedAt.millisecondsSinceEpoch}'This way the new URL automatically bypasses the old cache entry.
Bonus: Preloading Images for Smooth UX
For critical images (the next page in a carousel, the header of a detail page), preload them before the user navigates:
// In your list item, preload the detail page hero image
@override
void initState() {
super.initState();
// Start downloading the image when this list item appears
AppCacheManager().downloadFile(product.heroImageUrl);
}When the user taps through to the detail page, the image is already on disk — instant display, no loading state.
Measuring the Impact
Before optimizing, measure. Add this to your app to see cache stats:
// Check cache size
final cacheInfo = await AppCacheManager().getFileFromCache(imageUrl);
debugPrint('Cached: ${cacheInfo != null}');
debugPrint('File size: ${cacheInfo?.file.lengthSync()} bytes');
// Total cache size on disk
final cacheDir = await getTemporaryDirectory();
final cacheSize = await _calculateDirSize(Directory('${cacheDir.path}/appImageCache'));
debugPrint('Total cache: ${(cacheSize / 1024 / 1024).toStringAsFixed(1)} MB');Key Takeaways
Image.networkhas no disk cache. Your users re-download images every session.cached_network_imageis the standard. It handles disk + memory + placeholders + errors.- Always set
memCacheWidth. A 2000px image displayed at 100px wastes 44x memory. - Configure cache limits based on your app type. Don't let the cache grow unbounded.
- Build one reusable
AppImagewidget. Consistency across your app, zero repeated boilerplate. - Use versioned URLs for cache invalidation instead of clearing the entire cache.
- Preload critical images for instant transitions between screens.
Image performance is the single biggest impact you can have on perceived app speed. Users don't consciously notice when images load instantly — but they absolutely notice when they don't. Get this right and your app feels a generation ahead.
Find me at: itszain.dev