BLOG · #Engineering

Channels, Pigeon, and FFI: crossing to native without paying per tick

Flutter offers three ways to reach native code, with different costs and different failure modes. Picking correctly is mostly about one number — how often you cross — and one rule: batch on the frame, never on the tick.

Also in 中文. Flutter implementation notes for Trading app architecture; see also the cross-platform boundary.

There are three ways out of Dart, and they are not interchangeable. Choosing well is less about performance benchmarks than about matching the mechanism to how often you cross and what you need on the other side.

The three mechanisms

Platform channels (MethodChannel, EventChannel) send messages through the engine to platform code. They are asynchronous, they serialize arguments through a codec, and they hop threads. That makes them ideal for platform capabilities — permissions, the camera, biometrics, share sheets — where calls are occasional and the work is on the platform side anyway. Cost is dominated by the per-call overhead, not by argument size.

Pigeon is code generation on top of channels. You declare an interface once, and it generates typed Dart and platform code. The runtime cost is the same as a hand-written channel; what you get is the elimination of an entire class of bug — misspelled method names, mismatched argument shapes, silent type coercion — that channels invite because they are stringly typed and untyped in both directions. If you have more than a couple of channel methods, use it.

dart:ffi calls C directly. It is synchronous, has no serialization, and can share memory with Dart via typed data. It is the only option when you need to touch a native library’s API surface directly or move large buffers cheaply. The catch is exactly its virtue: a synchronous call runs on the calling isolate, so a slow FFI call blocks whatever isolate invoked it — and if that’s the UI isolate, you have dropped frames. As of Flutter 3.47 / Dart 3.13 (August 2026), the supported way to bundle the native side is a build hook (hook/build.dart) in an FFI package — flutter create --template=package_ffi, recommended since Flutter 3.38 — which replaces the old per-platform plugin scaffolding for C code.

The number that decides it

The cost of crossing is paid per call. This one fact settles most designs.

A feed delivering 5,000 messages a second, marshalled individually over a channel, is 5,000 serialization round trips and thread hops per second — and it will be slower than doing all the work in Dart, despite native code being “faster.” You would have paid thousands of transitions to save some decoding.

The same feed, decoded and conflated natively with Dart reading a snapshot once per frame, is 60 crossings a second carrying a batch. Same data, two orders of magnitude fewer boundary costs.

So the rule is the one from the pipeline article, stated for Flutter: cross on the frame, never on the tick. Design the native interface as a small number of coarse operations that move a batch, not as a stream of individual events or a set of property getters.

A concrete shape for a high-rate feed

If you do end up with native ingest, the interface that works looks like this: native owns the socket, decodes, detects gaps, and maintains a current-value table. Dart asks for what changed since it last looked.

final class QuoteBridge {
  QuoteBridge(this._lib);
  final NativeLib _lib;

  /// Copies changed quotes into a reusable buffer. One crossing per frame.
  int drainInto(Float64List out) => _lib.drain(out.address, out.length);
}

Two properties matter here. The buffer is reused rather than allocated per call, so you are not creating garbage 60 times a second. And the call returns a count into a preallocated region rather than constructing objects across the boundary, because allocating Dart objects from native code per element reintroduces the per-item cost you were avoiding.

Then keep the FFI call off the UI isolate if it can ever be slow:

// Long-running or blocking work belongs on its own isolate.
final decoded = await Isolate.run(() => heavyDecode(bytes));

For native code that needs to call into Dart — a completion callback, a push notification from the feed — use NativeCallable, which gives you a function pointer that safely re-enters the Dart isolate. Do not try to invoke Dart from an arbitrary native thread without it.

Memory ownership must be explicit

FFI removes the serialization boundary, and with it the automatic memory management you were implicitly relying on. Decide and document, for every buffer crossing the line, who allocates and who frees.

The two patterns that hold up:

  • Dart allocates, native fills. Dart owns a typed-data buffer for its whole lifetime and passes it down. Simplest, and my default — there is no ambiguity about lifetime.
  • Native allocates, Dart frees explicitly. Wrap it so release is not optional: using((arena) { ... }) for scoped allocations, or attach a NativeFinalizer so a dropped Dart object releases its native peer.

The failure here is not a crash you catch in testing. It’s a slow leak that shows up as a memory graph climbing over an hour of use, which is precisely the session length you never test.

EventChannel has no backpressure

Worth stating on its own, because it surprises people: an EventChannel fed faster than Dart consumes will queue. There is no built-in mechanism for the consumer to say “slow down,” so a fast native producer turns into unbounded buffering and then a memory problem. (The symptom is on record as far back as flutter/flutter#33640: a native producer feeding an EventChannel continuously, and memory climbing at a steady rate.)

If native is the producer of a high-rate stream, conflate on the native side and let Dart pull, or push at a rate you control (once per frame) rather than at the rate data arrives. Applying backpressure is the producer’s job here because the transport won’t do it for you.

Choosing, briefly

  • Occasional platform capability → Pigeon-generated channel. Type safety is free; the call rate is irrelevant.
  • A handful of calls, no codegen appetite → a plain MethodChannel is fine. Keep the method surface small.
  • Large buffers, or a native library you must talk to directly → FFI, with explicit ownership and a helper isolate for anything slow.
  • High-rate stream → native conflates, Dart drains once per frame over FFI. Never per-message channels.
  • Anything you can do in Dart at acceptable cost → do it in Dart. The boundary is a permanent tax on debugging, crash reporting, and build complexity, and it should be justified by a measurement rather than an intuition about speed.

That last one is the most common mistake. A stack trace stops at the boundary, your crash reporter needs separate symbol uploads for each side, and a reproduction now involves two toolchains. Cross when the profile says to, and not because native feels faster.

References

API and renderer claims checked against Flutter 3.47.0 / Dart 3.13 (August 2026).


Previous: Flutter under a firehose · Next: Charts and custom painting