image_ffi makes thumbnails in native code. The synchronous call blocks, so there is an async variant that runs the work on a background isolate and keeps the UI thread free:
final thumb = await thumbnailJpegAsync(bytes, maxDimension: 256);
One image at a time this is exactly right. The problem starts when you have a folder of them and reach for the obvious way to do all of them at once.
final thumbs = await Future.wait(
images.map((bytes) => thumbnailJpegAsync(bytes, maxDimension: 256)),
);
This reads like "make all the thumbnails," and for ten images it is fine. For a real photo library it is a memory bomb. Future.wait starts every future eagerly, so thumbnailJpegAsync is called for every image before any of them finishes. Each call spins up its own isolate, and each isolate decodes a full-size image into a pixel buffer before it downscales. A 4000 by 3000 photo is about 48 MB decoded. Two hundred of those in flight at once is more than nine gigabytes of live pixel data, spread across two hundred isolates, all trying to run at the same time. On a phone the process is killed long before it gets there.
The isolate is what makes it dangerous. Moving work off the main isolate is the right instinct, but Future.wait has no idea it is fanning out to isolates, and nothing between your loop and the OS is counting how many are alive. The unbounded fan-out that would just be slow with plain async futures becomes a memory ceiling with one native buffer per isolate.
Bound the number in flight
The fix is not to stop using isolates. It is to cap how many run at once. image_ffi 0.5.0 adds a batch call that does exactly that:
await for (final thumb in thumbnailJpegBatch(images, concurrency: 4)) {
// write thumb to disk as it arrives
}
thumbnailJpegBatch runs the same per-image work off the main isolate, but never lets more than concurrency of them be live at once. It holds a semaphore, and a new image only claims an isolate when a previous one has finished and released its permit. Four in flight means at most four decoded buffers in memory at any moment, whatever the size of the folder. concurrency defaults to the processor count, which is the number that keeps the cores busy without stacking work that cannot run yet. There is a thumbnailPngBatch with the same shape for images whose transparency you want to keep.
Results come back as a stream, in the order they finish rather than the order of the input, so you write each thumbnail as it lands instead of waiting for the whole folder and holding every result in a list. If you need to pair a result with its source, carry the identity alongside the bytes before you call. That is the one thing the ordering costs you, and it is a small price for a memory profile that stays flat no matter how many photos you point it at.
The rule underneath is older than this package: Future.wait over a sequence starts all of it at once. When each item is cheap that is a feature. When each item spawns an isolate and pins a large buffer, it is an unbounded fan-out wearing the mask of a one-liner, and the fix is always to put a bound on how many run at the same time.

Top comments (0)