Back to Portfolio

Flutter · isolate

Merging Images with Isolate

How I fixed a frozen loading spinner by moving heavy image processing off the main thread.

5 min read

The Problem

In a real project, users needed to upload both sides of their ID card as a single merged image.

I found a merge function online — but the merge itself wasn't the issue.

The submit button showed a CircularProgressIndicator… but the moment the merge ran, the spinner froze. The whole app stuttered.

Why? Because the merge function runs all of this on the main thread:

  1. 1Read image files from storage
  2. 2Decode the images
  3. 3Create a new canvas
  4. 4Merge pixels one by one
  5. 5Save the result

All on the main thread — blocking Flutter from redrawing the UI.

Before — No Isolate

UI freezes during merge
dart
Future<File> mergeImagesVertically(File front, File back) async {
  final img.Image image1 = img.decodeImage(await front.readAsBytes())!;
  final img.Image image2 = img.decodeImage(await back.readAsBytes())!;

  final int finalWidth = max(image1.width, image2.width);
  final int finalHeight = image1.height + image2.height;

  final img.Image canvas = img.copyExpandCanvas(
    image1,
    newWidth: finalWidth,
    newHeight: finalHeight,
    position: img.ExpandCanvasPosition.topLeft,
  );

  final img.Image finalImage = img.compositeImage(
    canvas, image2, dstX: 0, dstY: image1.height,
  );

  final dir = await getApplicationDocumentsDirectory();
  final path = join(dir.path, 'merged_${DateTime.now().millisecondsSinceEpoch}.jpg');
  final file = File(path);
  await file.writeAsBytes(img.encodeJpg(finalImage));
  return file;
}

After — With Isolate

Smooth UI while merging
dart
class MergeImagesArgs {
  final String frontPath;
  final String backPath;
  final String outputPath;
  MergeImagesArgs(this.frontPath, this.backPath, this.outputPath);
}

Future<String> _mergeImagesInIsolate(MergeImagesArgs args) async {
  final front = File(args.frontPath);
  final back = File(args.backPath);

  final img.Image image1 = img.decodeImage(await front.readAsBytes())!;
  final img.Image image2 = img.decodeImage(await back.readAsBytes())!;

  final int finalWidth = max(image1.width, image2.width);
  final int finalHeight = image1.height + image2.height;

  final img.Image canvas = img.copyExpandCanvas(
    image1,
    newWidth: finalWidth,
    newHeight: finalHeight,
    position: img.ExpandCanvasPosition.topLeft,
  );

  final img.Image finalImage = img.compositeImage(
    canvas, image2, dstX: 0, dstY: image1.height,
  );

  final file = File(args.outputPath);
  await file.writeAsBytes(img.encodeJpg(finalImage));
  return file.path;
}

Future<File> mergeImagesVerticallyInBackground(
  File frontImage, File backImage,
) async {
  final dir = await getApplicationDocumentsDirectory();
  final outputPath = join(
    dir.path, 'merged_${DateTime.now().millisecondsSinceEpoch}.jpg',
  );

  final mergedPath = await compute(
    _mergeImagesInIsolate,
    MergeImagesArgs(frontImage.path, backImage.path, outputPath),
  );

  return File(mergedPath);
}

The Key Change

dart
final mergedPath = await compute(
  _mergeImagesInIsolate,
  MergeImagesArgs(frontImage.path, backImage.path, outputPath),
);
  • `compute` spawns a new isolate (parallel thread) automatically.
  • `_mergeImagesInIsolate` runs inside this isolate — off the main thread.
  • It receives a `MergeImagesArgs` with front path, back path, and output path.
  • Returns the merged image path. Main thread stays free to animate the UI.

Further Reading

  • Dart Guide — Isolates
  • Flutter API — compute()
  • Flutter Performance with Isolates
  • Real-World Isolate Usage