Your Plugin Call Doesn't Need an Isolate

Wait 5 sec.

Almost two years ago, I answered a Flutter question on Stack Overflow. OP was resizing and compressing images, his UI was janky, and he'd tried to push the work into an isolate, only to hit a cryptic UnimplementedError. Tricky case, especially back then, and I didn't know the exact answer, so I got curious and started to dig. Long story short, I came up with a proper (as I thought) fix for his exact error. It worked flawlessly, OP was happy. The answer got accepted, even got an upvote, and I moved on. I was happy too, because answering this question was educational for me as well: I finally had a hands-on case with a long-lived isolate, not the most common thing in the simple UI development I was doing at the time.Last week, I was clearing out an old notes folder and found my scratch file for that answer — the sketches, the dead ends, the version that finally shipped. Two years of doing this for a living had passed, and reading it back, a different instinct kicked in. Back then, I'd only asked one question: *how* do I make a plugin work inside an isolate. This time, the more experienced version of me asked a blunter one: did any of this need to be there at all? Nothing was broken: the answer still works; I re-ran it. But I couldn't shake it: why was I doing that in the first place?The problem keeps coming back. In one issue, a developer wraps image compression in Isolate.run, hits the same wall, and the discussion points him at BackgroundIsolateBinaryMessenger.ensureInitialized, the fix I would later write up myself.In another, still open today, someone reports that he got it working by dropping Dart's own Isolate.spawn and pulling in a third-party package whose entire job is to spawn isolates that plugins can talk to. It doesn't support Web, and it doesn't work on macOS.A dependency is a cheap thing to add and an expensive thing to own, and this one was bought to solve a problem nobody in the thread had measured.The threads argue about where ensureInitialized belongs, whose bug it is, which package to swap in. The naive fix works, more or less, and that's where the discussion stops. Nobody asks whether the compression needed to be in an isolate at all, and neither did I.Understanding isolates was never the hard part. Dart doesn't tell you which line is the expensive one, so the isolate gets pointed at whatever looks heavy. A plugin call looks heavy. img.decodeImage looks like a cheap function call.So I took my own two-year-old answer, put it on a bench, and measured what it had actually been protecting against. It turned out to be a correct answer to a question nobody had asked, and two of the numbers went the opposite way from what I expected.The scene, and my old answer as it stands.Here's what OP was working with, a simplified version to be exact. The pipeline is ordinary: decode the image, crop it to a square, resize it, then hand it to flutter_image_compress to squeeze it down. Everything happens inside the isolate:Future process(Uint8List bytes) { return Isolate.run(() async { final decoded = img.decodeImage(bytes)!; final square = cropToSquare(decoded); // center-crop helper final resized = img.copyResize(square, width: 1000, height: 1000); final png = img.encodePng(resized); // this line explodes: return FlutterImageCompress.compressWithList(png, quality: 20); });}Here's the short version of why compressWithList throws UnimplementedError. A plugin call isn't a function call — it's a message. MethodChannel serializes your arguments and ships them over a BinaryMessenger to the native side, which runs the real code and ships a result back. The main isolate has that messenger wired to the engine, but a freshly spawned isolate does not. So when compressWithList tries to send its message, there's nobody on the other end of the pipe, and you get UnimplementedError.That's the part I got right two years ago. The fix itself was described in the Flutter documentation, and was essentially about passing a `RootIsolateToken` to the new background isolate and letting it register itself with the engine before it makes any plugin calls:static void _isolateMain(IsolateInitData init) { final receivePort = ReceivePort(); init.sendPort.send(receivePort.sendPort); // the one line that makes plugins work in here: BackgroundIsolateBinaryMessenger.ensureInitialized(init.token); receivePort.listen((message) async { final result = await _processOne(message); // decode/crop/resize/compress message.responsePort.send(result); });}Everything in there is plumbing — a port, a message protocol, a response channel. The single load-bearing line is `ensureInitialized`; the rest is just how you get data in and out of an isolate.So yes, I created a long-lived background worker capable of working with platform plugins and, as a bonus, got rid of the small but real overhead of spawning an isolate for each call. The complete code is in the repository, if you're curious.I re-ran all of it last week. The UnimplementedError still reproduces exactly, and my fix still makes it disappear. It's arguably a perfect answer to the question that was asked.So what's actually wrong with it? Fair question — I asked myself the same one, and honestly, nothing, as far as the code goes. The problem isn't the code itself, but the overall approach.Look at that snippet again. Two different kinds of work are happening inside it. Most of it is plain Dart — decodeImage, cropToSquare, copyResize, encodePng, all from the image package, all ordinary synchronous code. And one line is completely different: compressWithList computes nothing in Dart at all; it packs the bytes up and hands them to native code.Back in 2024, I wasn't as meticulous as I am now, and this distinction never crossed my mind. But what I should have asked back then is "which part is heavy?" and "what exactly hangs the UI?"So, I split the block in two and measured each half on its own. Let's start with the one the entire isolate existed to protect against: the plugin call.Weighing the plugin first.To ask "Does this freeze the UI?" you need something that behaves like the UI. So I set up a metronome — a timer ticking every 16 ms, one frame at 60fps — that logs how late each tick actually lands. Run work next to it, and the gaps show up: even spacing means the event loop stayed free; a gap is where a user would see a stutter.var last = Stopwatch()..start();Timer.periodic(const Duration(milliseconds: 16), (_) { final slip = last.elapsedMilliseconds - 16; // how late this tick landed last.reset(); if (slip > 1) print('frame slipped by ${slip}ms');});Then I ran just the compress on the main isolate, no background isolate anywhere. And to make any cost show up at all, I gave it the worst input in play: the full 24-megapixel source, not the small already-resized image OP's pipeline actually hands it. If the plugin is going to stall something, this is where it would.I tried both ways of calling it. compressAndGetFile — a file path in, a file path out — the worst gap was 1.8 ms: below a single frame. The byte-based compressWithList was worse: 35 to 150 ms, jumping around between runs.But that 35–150 ms isn't the compression. The real work — decoding and resizing a 24-megapixel image — runs natively, on a background thread the plugin dispatches to, and never comes near the Dart event loop. What you pay for is shipping an 18 MB byte array across the method channel and back. Hand over a path instead of bytes, and there's nothing to ship. That's why the path version sits at 1.8 ms and the byte version bounces.So, the worst framing I could construct is a hitch, not a freeze, and the realistic one is 1.8 ms. The native call, the thing I blamed from the start, was never worth dumping into an isolate.Okay, so the plugin wasn't the reason for the jank; something else was — and I'd spent two years assuming I knew what.The half I never suspectedSo I moved the metronome to the other half — the plain-Dart pipeline, decodeImage through encodePng, on the main isolate. Same test, same 24-megapixel image.This time, the timer stopped firing for several seconds: more than three hundred frames dropped in a row. Basically, this pipeline freezes the app; on Android, you can even get an ANR.The plugin, the part I'd built the whole isolate around, cost 1.8 ms. The plain-Dart processing next to it froze the UI for five seconds. The half I never suspected was the entire problem.The time goes where the pixels are: decode and crop scale with the input, while resize and encode stay flat, bounded by the fixed 1000px output.So the isolate was the right call — I just had it doing the wrong thing: leave the Dart part inside, move the plugin call out.With the same decode/crop/resize/encode wrapped in a plain Isolate.run, the metronome barely moved: 13 ms, one frame. The freeze was gone.Two years late, the isolate was finally wrapped around the work that actually froze the UI. What I couldn't answer was why that work existed in the first place.Do you even need any of it?Here's the awkward part. The only thing that still needed the isolate was the Dart pipeline. So why was there a Dart pipeline at all?It's there to resize the image before compressing it: decode, crop, resize, re-encode, all in Dart, via the image package. But flutter_image_compress doesn't only compress. compressWithList and compressAndGetFile take minWidth and minHeight; it can resize on the native side, in the same call that compresses. Which means the image package — the whole reason there was a five-second freeze to isolate in the first place — might not need to be there either.So I deleted it. One native call, a source path in, a target path out:final out = await FlutterImageCompress.compressAndGetFile( src, dst, minWidth: 1000, minHeight: 1000, quality: 20,);A 6000×4000, 18 MB source came back at 1500×1000 and 11 KB. No image package. No isolate. The metronome never twitched. It's a native call, and those don't touch the event loop.I'd answered this with a long-lived background isolate, a RootIsolateToken, ensureInitialized, and a typed port protocol. The honest version of the same result is a single function call on the main thread, with no need for isolation anywhere.That's the ending I wanted, but it's not quite the whole truth.The catch is in that 1500×1000. OP didn't want to just shrink the photo: he wanted a centered square, 1000×1000. And flutter_image_compress won't do that. It has minWidth and minHeight, but those are lower bounds on a proportional resize: it scales the image down until it fits them, keeping the aspect ratio. Feed it a 3:2 photo, and you get a 3:2 result — 1500×1000, never a square.This isn't "native can't crop." Android and iOS crop bitmaps all day; it's that the plugin doesn't expose the knob, and there's no crop parameter anywhere in its API. That's a different problem, and exactly the kind of thing that's easy to miss when you say "just do it natively" without checking.So the honest answer forks on a single requirement:If OP needs that square crop, it's real Dart work, and it belongs in an isolate — for the crop, not for the plugin. The isolate comes back, this time around the work that needs it.If he doesn't — a proportional resize is fine — it's one native call, and there's no isolate in the picture at all."Delete the isolate" would be a punchy way to end this, but it's only true down one of those branches. The smaller, harder-to-argue version: the isolate was never about the plugin, and whether you need it at all comes down to one line in the requirements.Two years on…None of this needed more knowledge about isolates than I already had in 2024. It needed a measurement I hadn't bothered to run. Back then, I asked, "How do I get a plugin to run in a background isolate?" and answered it well. The question I skipped was "Should this be in an isolate at all?" And that one takes a timer and two runs to answer.The isolate wasn't the mistake. I'd aimed it at the wrong half: the plugin never blocked anything, the plain-Dart work did, and depending on one line in the spec, you might not need either.This isn't to say platform channels in background isolates are useless — sometimes you genuinely need one (a background download, work that outlives the UI). When you do, the RootIsolateToken and BackgroundIsolateBinaryMessenger approach is the right answer.TakeawaysBefore anything else: if you don't know which line blocks the event loop, you don't know whether you need an isolate. A Timer.periodic metronome answers that in one run, and everything below assumes you've done it.If the heavy step is a plugin call, find out where the plugin does the work before you touch an isolate. A well-behaved one hands it to a background thread and returns immediately; that's why flutter_image_compress cost under two milliseconds. One that works inside onMethodCall will freeze you instead, and an isolate won't save you from it: I wrote one that blocks, and calling it from a background isolate stalled the UI just as long. That's a fix for the native side, not something Dart can do for you.If that call passes a large byte array and it does show up on the metronome, don't reach for an isolate — change the signature. Paths cost almost nothing, whereas an 18 MB array costs a hitch. What you're paying for is the trip across the channel.If the heavy step is plain Dart — decodeImage, copyResize, anything from the image package that touches every source pixel — that's your freeze, and that's what goes in Isolate.run. The cost scales with the source image, not with the output, so a fixed 1000px target buys you nothing on decode. If the isolate is long-lived, kill it in dispose().If the plugin can do the whole job natively, delete the Dart pipeline and the isolate with it. Check that before you build a pipeline to feed it.If it can't — you need a centered square, an exact geometry, a crop — the Dart work stays, and it stays in an isolate. "Just do it natively" isn't free advice; check that the knob exists.If you're about to add a package to make plugins work inside an isolate, stop and re-read the first paragraph.