devShakib

Talking to Native: FFI, Pigeon, and Knowing Which One You Need

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.

The three Flutter native interop paths, and what they actually cost

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.

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.

Where raw MethodChannel bites you in production

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:

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.

dart:ffi for synchronous C and Rust interop

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:

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.

Pigeon for type-safe platform channels

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:

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.

Threading: where the native boundary really lives

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.

So the real rules:

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.

Passing big payloads without killing the codec

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.

Packaging native code so consumers don't need a toolchain

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:

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.

A Flutter native interop decision tree you can apply on Monday

Strip away the nuance and the choice is short:

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.

Key takeaways

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.