← All posts

6 min read

Working with Images in Flutter: Picking, Compressing, Uploading and Caching

Handling images in Flutter end to end: image_picker, compressing before upload, progress with dio, cached_network_image, cacheWidth for memory and EXIF orientation.

  • Flutter
  • Images
  • Performance
Cover illustration for Working with Images in Flutter: Picking, Compressing, Uploading and Caching

A modern phone camera produces photos that are several megabytes and thousands of pixels wide. Your avatar circle is 48 logical pixels. Somewhere between those two numbers, a lot of Flutter apps quietly waste bandwidth, memory and battery.

Images touch almost every app I build, and the problems are always the same: uploads that take forever on mobile data, scrolling lists that stutter, photos that show up sideways on the server, and memory warnings on older devices. Here’s the pipeline I use, from the moment the user taps “add photo” to the moment that photo shows up in a feed.

Picking: image_picker and permissions

image_picker is still the default choice, and it does more than people expect. You can ask it to downscale and recompress on the way in:

final picker = ImagePicker();

Future<XFile?> pickPhoto() {
  return picker.pickImage(
    source: ImageSource.gallery,
    maxWidth: 2048,
    maxHeight: 2048,
    imageQuality: 85,
  );
}

That alone fixes a lot. A 2048-pixel JPEG at quality 85 is plenty for nearly every in-app use, and it’s a fraction of the original size.

Permissions are where people overdo it:

  • Android: Recent versions of image_picker use the system Photo Picker where it’s available. The user picks specific photos, and your app gets access to just those. No storage permission needed. If you’re still requesting READ_EXTERNAL_STORAGE or READ_MEDIA_IMAGES only to let users choose a photo, remove it. It’s an extra scary dialog for nothing, and Google Play now asks apps that hold broad photo permissions to justify them.
  • iOS: The gallery picker runs out of process, but you still need the usage description keys the plugin’s README lists in Info.plist: NSPhotoLibraryUsageDescription, plus NSCameraUsageDescription if you use the camera. Write real sentences there. “This app needs access” is a common reason for App Review pushback.

One Android-specific trap: the system can kill your activity while the camera is open, especially on low-memory devices. When that happens, the picked image comes back through picker.retrieveLostData() on the next launch. If photo capture is core to your app, handle it.

Compress and resize before upload

imageQuality and maxWidth on the picker handle most cases. When I need more control, say a specific output format, a smaller thumbnail alongside the full image, or processing images that didn’t come from the picker, I use flutter_image_compress. It runs natively, so it’s much faster than decoding and re-encoding in Dart.

Future<Uint8List?> compressForUpload(String path) {
  return FlutterImageCompress.compressWithFile(
    path,
    minWidth: 1600,
    minHeight: 1600,
    quality: 80,
    format: CompressFormat.jpeg,
  );
}

Despite the names, minWidth and minHeight work as target bounds: the image is scaled down to fit them, keeping its aspect ratio.

If you ever find yourself decoding images with the pure-Dart image package on the main isolate, stop. It works, but it will freeze the UI for a noticeable moment on a large photo. If you must use it, run it with Isolate.run.

The sideways photo problem

Phones often don’t rotate the pixels when you hold the camera sideways. They save the image as-is and add an EXIF orientation tag that says “rotate this 90 degrees when displaying.” Most viewers respect the tag. Some servers, image processors and older browsers don’t.

The safest fix is to bake the rotation into the pixels before upload and strip the tag. flutter_image_compress does this by default (look at its autoCorrectionAngle and keepExif options). Stripping EXIF has a bonus: it removes GPS coordinates and device details, which you almost certainly don’t want to publish with a user’s profile photo. That belongs on your mobile security checklist too.

Uploading with progress

A spinner on a 10-second upload feels broken. A progress bar feels fine. dio makes this easy with multipart form data and an onSendProgress callback:

Future<String> uploadPhoto(
  Dio dio,
  Uint8List bytes, {
  required void Function(double progress) onProgress,
  CancelToken? cancelToken,
}) async {
  final form = FormData.fromMap({
    'file': MultipartFile.fromBytes(
      bytes,
      filename: 'photo.jpg',
      contentType: DioMediaType('image', 'jpeg'),
    ),
  });

  final response = await dio.post<Map<String, dynamic>>(
    '/uploads',
    data: form,
    cancelToken: cancelToken,
    onSendProgress: (sent, total) {
      if (total > 0) onProgress(sent / total);
    },
  );

  return response.data!['url'] as String;
}

A few details that matter in production:

  • Pass a CancelToken. Users back out of screens. Don’t keep uploading to a form they abandoned.
  • Expect total to be -1 sometimes. Guard the division, as above.
  • Don’t block the UI on the upload. Let the user keep filling in the form while the photo uploads, and only disable “Submit” until it finishes. It feels twice as fast without being any faster.
  • Consider direct-to-storage uploads. For large files, having your API hand out a short-lived signed URL and uploading straight to object storage keeps big payloads off your API servers. I go into that pattern in using Azure with Flutter apps.

If uploads have to survive the app being closed, that’s a different problem with different tools: platform background transfer APIs, not a Future you hope finishes.

Displaying: caching and memory

Downloading is the cost people notice. Decoding is the cost people miss.

When Flutter displays an image, it decodes it to raw pixels in memory. A 2000x2000 image is about 16 MB decoded (four bytes per pixel), regardless of how small the JPEG was. Put twenty of those in a list of thumbnails and you’re holding hundreds of megabytes to show tiny squares. That’s how you get jank while scrolling and out-of-memory crashes on older Android devices.

The fix is to tell Flutter to decode at the size you’ll actually display:

Image.network(
  url,
  width: 96,
  height: 96,
  fit: BoxFit.cover,
  cacheWidth: (96 * MediaQuery.devicePixelRatioOf(context)).round(),
)

cacheWidth and cacheHeight make the decoder produce a smaller bitmap. Set one of them to keep the aspect ratio. Multiply by the device pixel ratio, or thumbnails look blurry on high-density screens.

For anything loaded from the network more than once, I use cached_network_image. It keeps a disk cache, so revisiting a screen doesn’t download everything again, and it supports the same decode-size trick via memCacheWidth:

CachedNetworkImage(
  imageUrl: product.thumbnailUrl,
  width: 96,
  height: 96,
  fit: BoxFit.cover,
  memCacheWidth: (96 * MediaQuery.devicePixelRatioOf(context)).round(),
  placeholder: (context, url) => const ColoredBox(color: Color(0xFFE0E0E0)),
  errorWidget: (context, url, error) => const Icon(Icons.broken_image_outlined),
)

Better still, have your backend or CDN serve resized variants (a thumbnail URL and a full-size URL) so the phone never downloads a 2000-pixel image to show 96 pixels. cacheWidth saves memory. Server-side resizing saves memory and data.

If image decoding is showing up in your performance traces, my post on fixing jank in Flutter apps covers how to confirm it in DevTools.

Placeholders that don’t jump

The worst image experience isn’t slow loading. It’s layout shift: text renders, then an image pops in and shoves everything down just as the user is about to tap.

Always reserve the space. Give images a fixed size, or wrap them in an AspectRatio if you know the ratio (store it with the image metadata on the backend when you can). The placeholder fills that space with a neutral color, a blurred tiny preview, or a subtle shimmer. Whichever you pick, the layout shouldn’t move when the real image arrives.

A short fadeInDuration (cached_network_image has one) makes the swap feel smooth. Keep it short. A slow fade on every image in a list makes the whole app feel sluggish.

A quick pipeline summary

Stage Tool Main goal
Pick image_picker Downscale on the way in, minimal permissions
Process flutter_image_compress Resize, fix orientation, strip EXIF
Upload dio Progress, cancellation, non-blocking UI
Display cached_network_image / cacheWidth Disk cache, decode at display size
Layout Fixed size / AspectRatio No layout shift

Takeaways

  • Let image_picker downscale at pick time with maxWidth and imageQuality. It’s the cheapest win.
  • On Android, the system photo picker needs no storage permission. Don’t ask for one you don’t need.
  • Bake EXIF orientation into the pixels and strip metadata before upload, for both correctness and privacy.
  • Show real upload progress, support cancellation, and let users keep working while it runs.
  • Decode at display size with cacheWidth or memCacheWidth, and serve resized variants from the backend when you can.
  • Reserve image space up front so nothing jumps when images load.

Get the pipeline right once, wrap it in a small service, and you’ll never think about sideways profile photos again.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).