Flutter native interop compared: dart:ffi vs Pigeon vs MethodChannel. When to use each, type safe platform channels, FFI memory rules, threading, and codec tips.
A MethodChannel typo cost us three days and a hotfix release, and the compiler never said a word. That's the whole story of Flutter native interop in one sentence: the easy path is a stringly-typed message bus that fails silently in the field, and almost everyone reaches for it first.
Every Flutter developer's first brush with native code goes the same way. You need something the framework doesn't give you — a battery level, a hardware sensor, a C library your backend team already trusts — and the first search result says MethodChannel. You copy the snippet, wire up a stringly-typed channel name, and it works. Ship it.
Then it grows. Six months later that one channel has fourteen methods, each one a switch on a string, each argument a Map<String, dynamic> you as-cast and pray over. At Shpper we had exactly this: a device channel that had quietly become the single largest source of crash-free-rate regressions in one of our apps. The regression that cost us the three days was a renamed method on the Kotlin side that nobody renamed on the Dart side — green build, green tests, MissingPluginException on real hardware two days after release. Not one of those crashes was catchable by the compiler, because we'd built the boundary out of strings. The lesson I keep relearning: MethodChannel is the default answer and it is usually the wrong one.
This post is the decision framework I wish I'd had earlier: the three ways Flutter talks to native code, what each one actually costs, and how to pick before you write a line of glue.
Flutter gives you three real ways to reach native code. They are not interchangeable, and picking the wrong one is where the pain comes from.
dart:ffi — call C (and Rust, and anything with a C ABI) directly, in-process, synchronously. No serialization, no message passing, no platform thread. This is the fastest path and the one people reach for last.MethodChannel / EventChannel — the hand-written message bus between Dart and the platform (Kotlin/Java, Swift/Obj-C). Async, dynamically typed, and manual on both ends.Here's the mental model I use. FFI is for code — you have a native function and you want to call it. Pigeon is for platform APIs — you need to talk to Android or iOS SDKs and want a typed contract. Raw channels are for the awkward middle: event streams, plugin ecosystems, and things Pigeon can't express yet.
| | FFI | Pigeon | Raw MethodChannel |
|---|---|---|---|
| Talks to | C / Rust / C ABI | Kotlin / Swift SDKs | Kotlin / Swift SDKs |
| Call style | Synchronous | Async (Future) | Async (Future) |
| Type safety | Compile-time (C types) | Compile-time (generated) | None |
| Serialization | None (raw memory) | Standard codec | Standard codec |
| Runs on | Calling thread | Platform thread | Platform thread |
| Best for | Hot paths, existing C libs | New platform integrations | Streams, edge cases |
If you take one thing away: the interesting decision is between FFI and Pigeon. Raw channels are the fallback, not the starting point. Everything below is really about earning the confidence to not hand-write a channel by default.
The problem with raw channels isn't that they don't work. They work fine on the happy path, which is exactly why they're dangerous — the cost is deferred to the moment you least want it. A MethodChannel is a BasicMessageChannel with a method-call codec bolted on, and that's all the safety you get: a string name and a bag of dynamically-typed arguments.
Look at a typical hand-rolled channel:
const _channel = MethodChannel('com.shpper/device');Future<int> getBatteryLevel() async { final result = await _channel.invokeMethod('getBatteryLevel'); return result as int; // hope it's really an int}And the Kotlin side:
channel.setMethodCallHandler { call, result -> when (call.method) { "getBatteryLevel" -> result.success(batteryLevel()) // typo "getBateryLevel" here? compiles fine, fails at runtime else -> result.notImplemented() }}Three failure modes are baked in and none of them are caught by a compiler:
MissingPluginException in the field. Grep is your only "refactoring tool," and grep doesn't know the difference between a channel name and a comment.Object?. You cast on the Dart side and cast again in Kotlin. Change an argument's shape — an int that becomes a long, a field that becomes nullable — and nothing warns you until a specific device tries it. Worse, the standard codec silently promotes small integers, so a value that's fine in the emulator can ClassCastException on a payload that happens to exceed 32 bits.Future, even reading a constant. That async hop forces await into call sites that are conceptually synchronous, which spreads through your architecture and makes otherwise-pure functions infectious.Raw channels are the assembly language of Flutter interop. Sometimes you need assembly. You just shouldn't write your whole app in it — and you definitely shouldn't reach for it first for a plain typed request/response API.
When you actually have native code — an image codec, a crypto primitive, a Rust core you share across platforms — FFI is a different universe. There's no message bus. Dart calls the C function directly and gets the result back on the same thread, synchronously, with zero serialization.
On a project last year we needed to hash and verify a few thousand small records on-device during a sync. Doing it over a method channel meant a round trip per record, and the platform-thread hop killed us — the per-call overhead dominated the actual work. Moving the hot loop to a tiny C function behind FFI took the whole operation from "spinner the user notices" to "done before the frame ends." The win wasn't a faster hash; it was deleting the boundary entirely.
The mechanics: you declare the native signature and the Dart signature, then bind them.
import 'dart:ffi';import 'package:ffi/ffi.dart';// C: uint32_t crc32(const uint8_t* data, int len);typedef _Crc32C = Uint32 Function(Pointer<Uint8>, Int32);typedef _Crc32Dart = int Function(Pointer<Uint8>, int);final _lib = DynamicLibrary.open('libhash.so');final _crc32 = _lib.lookupFunction<_Crc32C, _Crc32Dart>('crc32');int crc32(List<int> bytes) { final ptr = malloc<Uint8>(bytes.length); final view = ptr.asTypedList(bytes.length); view.setAll(0, bytes); try { return _crc32(ptr, bytes.length); } finally { malloc.free(ptr); // you own this memory now }}Note the two typedefs: one uses native FFI types (Uint32, Int32, Pointer) to describe the C ABI, the other uses plain Dart types (int) for the call site. lookupFunction marries them. Get a width wrong — Int32 where the header says int64_t — and you'll read garbage or corrupt the stack, so this is exactly the place to let ffigen transcribe headers instead of doing it by hand.
That try/finally is the whole game with FFI. The moment you cross into native memory, Dart's garbage collector stops helping you. Every malloc needs a free, and if you throw in between, you leak. My rules after getting this wrong more than once:
dispose() and treat it like a file handle — and consider a NativeFinalizer as a backstop so a forgotten dispose() degrades to a late free instead of a permanent leak.package:ffi's malloc/calloc, not raw Pointer gymnastics — they give you .free() and helpers like .toNativeUtf8() for the ever-annoying string marshalling.asTypedList gives you a view into native memory; if that memory gets freed, the view is a dangling pointer and reading it is undefined behaviour. Copy into a Dart List before you free if the data escapes the function.Isolate.run or an async FFI callback via NativeCallable.listener so native code can call back into Dart from another thread.Two things make modern FFI far less painful. ffigen reads a C header and generates all the typedefs and bindings for you, so you're not hand-transcribing signatures and getting integer widths wrong. And flutter_rust_bridge does the same for Rust, generating the FFI glue and handling the memory dance so a Rust core feels like a normal async Dart API — including turning long-running Rust work into proper Dart Futures and Streams. If you have a real algorithmic core to share across platforms, that combination is the strongest option Flutter has.
FFI is great when the thing you're calling is C. But most native work isn't C — it's "please open the iOS share sheet" or "read this value from the Android KeyStore." That means talking to platform SDKs in Swift and Kotlin, and for that, the right tool is Pigeon.
Pigeon isn't a runtime; there's nothing to add to your app's dependency footprint at ship time. It's a code generator you run at build time. You write a schema in Dart — just abstract classes and data classes — and Pigeon emits the channel plumbing for Dart, Kotlin/Java, and Swift/Obj-C. The wire is still a method channel underneath. The difference is the compiler now sees both ends of it.
// pigeons/device_api.dart — this file is the schema, not shipped codeimport 'package:pigeon/pigeon.dart';class DeviceInfo { late String model; late int batteryLevel; late bool isCharging;}@HostApi()abstract class DeviceApi { DeviceInfo getDeviceInfo(); @async bool authenticate(String reason);}Run dart run pigeon --input pigeons/device_api.dart and you get a generated Dart class you call like any typed API, plus a Kotlin interface and a Swift protocol you implement. Now the compiler is your integration test:
class DevicePlugin : DeviceApi { override fun getDeviceInfo(): DeviceInfo { return DeviceInfo( model = Build.MODEL, batteryLevel = currentBattery(), isCharging = charging() ) } // forget authenticate()? Kotlin won't compile. That's the point.}What you get for free:
MissingPluginException from a one-character typo.Map<String, dynamic>. The DTO is defined once and generated everywhere, so there's a single source of truth for the shape crossing the boundary.HostApi and FlutterApi. @HostApi() lets Dart call into native; @FlutterApi() lets native call back into Dart. Both are typed, so bidirectional flows (a native SDK pushing a callback into your Dart layer) stop being a stringly-typed guessing game.A practical workflow note: check the generated files into version control and regenerate them in CI, then fail the build if the output changed. That turns "someone edited the schema but forgot to regenerate" into a red build instead of a runtime surprise — the same discipline I apply to any generated code.
My default now for any new platform integration is Pigeon first, and I only drop to a raw channel when I hit something Pigeon genuinely can't model. The generated code is boring, which on a boundary this error-prone is the highest praise I can give.
The part that trips people up isn't syntax — it's which thread the code runs on. Get this wrong and you'll ship an app that janks or, worse, deadlocks intermittently on some devices and never in the office.
await yields on the single UI isolate. It keeps the UI responsive for I/O-bound work, but a tight CPU loop in Dart still blocks the frame — concurrency and parallelism are not the same thing here.So the real rules:
Dispatchers.IO, a DispatchQueue.global()) and post the result back to the channel. Don't do the work inline on the platform thread.Isolate.run(() => crc32(bytes)) runs the synchronous FFI call on a separate isolate so the main one keeps rendering. Remember that data crossing isolate boundaries is copied unless it's transferable (like TransferableTypedData), so measure before you assume the isolate hop is free.EventChannel — a typed stream — rather than polling with repeated method calls. Polling turns a push problem into an N-round-trips-per-second problem.The mistake I see most: someone benchmarks a single channel call, sees it's "fast," and assumes it's free. Then they call it 60 times a frame in a scroll listener. The per-call cost is small; the platform-thread contention and codec churn at that frequency are not.
Every method channel serializes its arguments through the standard message codec. For small structured data that's fine. For big payloads it is a trap, and it's a trap you fall into precisely when performance matters most.
Two things go wrong at scale. First, a large Map or List is walked and encoded field by field — slow and allocation-heavy, with garbage-collection pressure on both sides. Second, that encode/decode happens on threads you care about, so a fat payload stalls the UI while it's being marshalled.
The fix is to stop sending structure and start sending bytes. The standard codec has a fast path for Uint8List — it's passed as a raw byte buffer, not walked element by element.
// Slow: a 100k-element list gets encoded item by itemfinal points = await channel.invokeMethod('getPoints'); // List<dynamic>, ouch// Fast: hand back a packed byte buffer, decode it in Dartfinal buffer = await channel.invokeMethod('getPointsBytes'); // Uint8Listfinal floats = buffer.buffer.asFloat32List();On the native side, pack your data into a ByteArray/Data and return that. You decode it in Dart with a typed-data view — no per-element codec cost, just a pointer reinterpretation. For a mesh of a few hundred thousand floats, this was the difference between a visible hitch and nothing at all. Mind endianness and struct alignment when you do this: agree on a layout (little-endian, tightly packed) and, if in doubt, use a ByteData with explicit getFloat32(offset, Endian.little) reads rather than a raw view.
And if you're already passing large buffers back and forth constantly, that's a signal you might want FFI instead. FFI shares memory by pointer — zero copy, zero serialization. The codec's byte fast-path is the right answer inside the channel world; FFI is the right answer when the channel world itself is the bottleneck.
This is the part that turns a working prototype into a shippable plugin, and it's where a lot of FFI projects quietly die. Your C or Rust code has to be built for every target — Android arm64/armeabi-v7a/x86_64, iOS device and simulator, and ideally macOS/Windows/Linux for desktop. If installing your package means "now go set up the NDK and a Rust cross-compiler," nobody will use it, including your future self on a fresh machine.
Flutter's plugin system does have hooks for this. The clean options:
.so/.a/.xcframework in CI and ship them inside the plugin (or, to keep the package small, download-on-first-build). This is the approach I lean toward — I already build binaries in CI and attach them to GitHub Releases for other parts of the stack, so the pattern is familiar and it keeps consumers toolchain-free. The trade-off is that you're responsible for reproducible, correctly-signed builds.podspec at your source so it compiles as part of the app build. Simplest to set up, but now every consumer needs the full native toolchain and eats the compile time on every clean build.flutter_rust_bridge + cargokit automates the Rust build across all targets and is the least painful route if you're in Rust — it wires the cross-compilation into the standard Flutter build so flutter build just produces the right artifacts.Whichever you pick, the goal is the same: flutter pub add your_package should just work, with no README section titled "First, install these seven tools." The moment native code needs a manual setup step, adoption falls off a cliff.
Strip away the nuance and the choice is short:
ffigen or flutter_rust_bridge around it, mind the memory (every malloc its free), and push slow calls off the UI isolate. Synchronous, zero-copy, no codec.HostApi on each side, and let the compiler catch your mistakes.EventChannel/MethodChannel — deliberately, knowing you own the type safety by hand and wrapping it thinly so the untyped surface doesn't leak through your app.Uint8List, not maps. And if you're doing it constantly, reconsider whether the problem is actually an FFI problem.Notice what's not on this list as a default: hand-writing a MethodChannel for a new typed API. That's the option to talk yourself out of, not into.
MethodChannel is the well-known answer and usually the wrong one — it's stringly-typed, untyped, always async, and every failure it hides shows up in production instead of at compile time.malloc needs its free, views into native memory can dangle, and slow calls belong off the UI isolate.Uint8List to hit the codec's fast path, and package your native binaries in CI so consumers never need a toolchain.The right tool depends entirely on whether you're crossing to code or to a platform API — answer that first, and the rest of the decision makes itself.