Platform Channels and Pigeon in Flutter: Talking to Native Code Safely
A practical guide to Flutter platform channels and Pigeon: MethodChannel and EventChannel basics, type-safe codegen, threading, error handling and when to skip it.

Flutter covers a lot of ground, but sooner or later you hit something only the platform can do. A system API with no plugin. A vendor SDK that ships only for Android and iOS. A car dashboard.
When I extended Nightingale to Apple CarPlay and Android Auto, native code was unavoidable. Those integrations live in the platform layer, and Flutter has to cooperate with them rather than replace them. Since then I’ve treated the bridge between Dart and native code as a small but serious piece of architecture.
This post walks through the questions I get asked most about that bridge, in roughly the order you’ll run into them.
Do I actually need to write native code?
Check first. Most of the time, someone has already done it.
Search pub.dev for the capability you need, and look at the platform badges, the publisher, how recently it was updated and the open issues. A well-maintained plugin has already handled the edge cases you haven’t thought of yet: permission flows, lifecycle changes, old OS versions, background restrictions.
I write my own channel code when:
- No plugin exists, or the existing ones are abandoned.
- I need a small slice of a big native SDK and don’t want the whole plugin’s surface area.
- The feature is specific to this app, like talking to a client’s own native library.
- The integration is deeply tied to the native app shell, as with car platforms. I go into that in Apple CarPlay and Android Auto with Flutter.
Whether a feature belongs in Dart at all is its own decision. For the rest of this post, let’s assume you’ve made it and native code is the answer.
How do MethodChannels work?
A MethodChannel is a named pipe between Dart and the host platform. Dart calls a method by name with arguments; native code handles it and replies with a result or an error. Everything is asynchronous and the values are serialized with the standard message codec (null, booleans, numbers, strings, byte arrays, lists and maps).
On the Dart side:
import 'package:flutter/services.dart';
class DeviceInfo {
static const _channel = MethodChannel('com.example.app/device');
Future<int?> batteryLevel() async {
try {
return await _channel.invokeMethod<int>('getBatteryLevel');
} on PlatformException catch (e) {
// Native code reported an error with a code and message.
debugPrint('Battery level failed: ${e.code} ${e.message}');
return null;
} on MissingPluginException {
// No handler registered on this platform.
return null;
}
}
}
On Android, in Kotlin:
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.example.app/device"
).setMethodCallHandler { call, result ->
when (call.method) {
"getBatteryLevel" -> {
val manager = getSystemService(BATTERY_SERVICE) as BatteryManager
val level = manager.getIntProperty(
BatteryManager.BATTERY_PROPERTY_CAPACITY
)
if (level >= 0) {
result.success(level)
} else {
result.error("UNAVAILABLE", "Battery level not available", null)
}
}
else -> result.notImplemented()
}
}
}
}
iOS looks the same in Swift with FlutterMethodChannel and a handler closure.
It works. But look at what nobody checks for you: the channel name is a string on both sides, the method name is a string on both sides, and the argument and return types are whatever each side assumes. Rename a method in Kotlin and forget Swift, and you find out at runtime, probably from a user.
What about streams of data?
For data that keeps arriving, like sensor readings, connectivity changes or playback state, use an EventChannel. Dart subscribes and receives a broadcast stream:
const _events = EventChannel('com.example.app/connectivity');
Stream<bool> get isOnline =>
_events.receiveBroadcastStream().map((event) => event as bool);
On the native side you implement a stream handler with onListen, where you start observing and push values into an event sink, and onCancel, where you stop. That second part matters. If you register a system listener in onListen and never unregister it in onCancel, you leak it, and it keeps running after the Dart side stops caring.
Why use Pigeon instead?
Pigeon fixes the stringly-typed problem. You describe the API once in Dart, and Pigeon generates matching Dart, Kotlin and Swift (and others) code: the channel setup, the serialization and the typed interfaces. You only write the actual implementation.
The definition file lives outside lib/, for example in pigeons/device_api.dart:
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(PigeonOptions(
dartOut: 'lib/src/device_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/com/example/app/DeviceApi.g.kt',
kotlinOptions: KotlinOptions(package: 'com.example.app'),
swiftOut: 'ios/Runner/DeviceApi.g.swift',
))
class BatteryInfo {
BatteryInfo({required this.level, required this.isCharging});
int level;
bool isCharging;
}
@HostApi()
abstract class DeviceApi {
BatteryInfo getBatteryInfo();
@async
String fetchVendorToken(String userId);
}
@FlutterApi()
abstract class DeviceEvents {
void onLowBattery(int level);
}
Generate the code with:
dart run pigeon --input pigeons/device_api.dart
The two annotations point in opposite directions:
@HostApi()is implemented on the native side and called from Dart. Pigeon gives you a Dart class with real typed methods, and a Kotlin interface and Swift protocol to implement.@FlutterApi()is implemented in Dart and called from native code. Useful for callbacks like “the car disconnected” or “playback finished.”
@async marks a method whose native implementation completes later through a callback, which is what you want for anything that does I/O.
In Kotlin, you implement the generated interface and register it:
class DeviceApiImpl(private val context: Context) : DeviceApi {
override fun getBatteryInfo(): BatteryInfo {
val manager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return BatteryInfo(
level = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).toLong(),
isCharging = manager.isCharging
)
}
override fun fetchVendorToken(userId: String, callback: (Result<String>) -> Unit) {
// Call the vendor SDK, then:
// callback(Result.success(token)) or callback(Result.failure(error))
}
}
// In configureFlutterEngine:
DeviceApi.setUp(flutterEngine.dartExecutor.binaryMessenger, DeviceApiImpl(this))
Notice that Dart’s int became Kotlin’s Long. Pigeon makes those mappings explicit, so you can’t get them wrong silently.
Now a rename is a compile error on every platform instead of a production bug. That’s the whole reason I default to Pigeon for anything beyond a single trivial method.
Which thread does my native code run on?
By default, channel handlers run on the platform’s main thread, the same one that drives native UI. That has two consequences:
- Don’t block it. Reading a large file, hashing data or waiting on a network call inside a handler freezes the app. Move heavy work to a background thread or coroutine and reply when it finishes, which is exactly what
@asyncmethods are for. - Reply on the main thread. If you hop to a background thread to do work, hop back before calling
result.successor invoking the callback. Some platforms are strict about this.
For handlers that are heavy by nature, Flutter supports background task queues on the binary messenger, and Pigeon exposes this through a @TaskQueue annotation on the API. I use it sparingly, because it changes threading assumptions in your native code and you need to know your implementation is thread-safe.
Calls from native into Dart through a @FlutterApi should also happen on the main thread.
How should errors cross the bridge?
Treat the bridge like a network boundary. Things will fail, and the Dart side needs to know how they failed.
- On the native side, report errors with a stable code, a human-readable message and optional details. With raw channels that’s
result.error(...); with Pigeon you throw or return the generatedFlutterErrortype (its class name is configurable if it clashes with something in your project). - On the Dart side, these arrive as
PlatformException. Catch them in your service class, not in widgets, and map the codes to your own domain errors. - Handle
MissingPluginExceptionwhen a platform simply doesn’t implement a feature, for example a call that exists on iOS but not Android, or any call on the web. - Never let native exceptions escape uncaught inside a handler. On Android, an uncaught exception there can crash the whole app, not just fail the call.
Native crashes also need their own reporting setup, since they don’t show up as Dart errors. I cover that in crash reporting and monitoring in Flutter.
How do I keep this testable?
Put every channel or Pigeon API behind a small Dart interface, such as DeviceService, and have widgets and state classes depend on that interface. In tests, swap in a fake. Your business logic never needs a real platform.
For testing the Dart side of a raw MethodChannel, the test binding lets you register a mock handler on TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger. For the native side, write normal native unit tests against your implementation class. Pigeon makes that easy because the implementation is just a class implementing an interface.
Takeaways
- Look for a maintained plugin first. Write native code when you need to, not by default.
- Use
MethodChannelfor calls andEventChannelfor streams, and always clean up inonCancel. - Prefer Pigeon for anything non-trivial. Type-safe generated code turns runtime bugs into compile errors.
- Keep the main thread free: use
@asyncfor I/O and reply on the main thread. - Return stable error codes from native and map
PlatformExceptionin one Dart service. - Hide the bridge behind a Dart interface so the rest of the app stays easy to test.
The bridge is a small part of most apps, but when it breaks, everything on the other side of it breaks too, so it deserves the same care as your API layer.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).