On Crumb Count, one feature took four days to build and two of those days were spent debugging a single boolean that Kotlin was interpreting as an integer. That feature was background-aware barcode scanning tied to a foreground service with a persistent notification, something Flutter’s plugin ecosystem covers about 80% of, right up until you need the last 20%: custom notification actions that call back into Dart while the app is backgrounded and the Flutter engine may or may not be alive. That’s where we dropped into native Kotlin, and where most “just use a plugin” advice stops being useful.
Why the plugin didn’t work
The obvious move is to grab an existing barcode/foreground-service plugin from pub.dev and wire it up. We tried three. Each one assumed a specific lifecycle shape: start scanning, get one callback, stop. Ours needed the scanning session to survive the user backgrounding the app, switching to another app to check a delivery note, and coming back — with the Dart-side state (a running tally, essentially a counter tied to SKUs) staying consistent the whole time. None of the plugins exposed a way to persist and rehydrate that state across an engine restart, because most of them assume the Flutter engine and the Dart isolate live for the life of the feature. On Android, they don’t. A foreground service is a Kotlin-side, engine-independent construct, and if the OS kills the Flutter engine for memory pressure while the service keeps running, you need a story for what happens when Dart comes back and asks “where were we?”
That’s not a plugin gap, it’s an architecture gap. No general-purpose plugin can encode your app’s specific state-recovery contract. Once we accepted that, the plugin question stopped being “which one” and became “do we even want the abstraction,” and the answer was no.
The actual boundary: MethodChannel plus EventChannel, not one or the other
We ended up with two channels doing different jobs, which is a detail that gets flattened in most tutorials:
- A
MethodChannelfor imperative, one-shot calls from Dart to native: start the service, stop it, request the current tally on cold resume. - An
EventChannelfor the native-to-Dart stream: every scan event, service lifecycle changes, and notification action taps, pushed as they happen rather than polled.
Using a single MethodChannel for both directions is the mistake we see most often in codebases we audit for other teams. It works in a demo. It falls apart once you need native to push data at arbitrary times, because MethodChannel has no notion of an open stream — you end up faking one with repeated invokeMethod calls from native back into Dart via a channel that was designed for request/response, and you lose backpressure handling entirely. First scan event arrives fine; the fifteenth arrives while Dart is still processing the twelfth, and now you’re debugging out-of-order UI updates that only reproduce on a scan-heavy SKU list.
The marshaling gotchas that actually bit us
Platform channels serialize through a standard codec that maps a fairly narrow set of Dart types to native ones. In practice, three things caused real bugs:
- Int vs Int64. Dart’s
intis 64-bit; Kotlin’s default numeric literal inference gave usInt(32-bit) on one code path. A SKU count over ~2.1 billion was never going to happen, but a timestamp in milliseconds since epoch overflowed a 32-bit field almost immediately. The channel didn’t error — it silently truncated, and the bug looked like corrupted scan timestamps for a week before we traced it back. - Null handling across the boundary. A nullable
String?on the Kotlin side that gets omitted from the arguments map doesn’t arrive asnullin Dart — the key is simply absent, so a naivearguments['barcode']read throws instead of returning null. Every native handler needs to explicitly populate every expected key, even when the value is empty. - Enums don’t cross the channel at all. We serialized scan-result state as an enum on both sides independently, then hardcoded the same string constants in Dart and Kotlin. The first time someone renamed a Kotlin enum case without updating the Dart-side switch, the app didn’t crash — it silently fell through to a default case and misreported scan failures as successes. That’s the class of bug that platform channels make easy to introduce, because the type checker on one side has zero visibility into the other side’s contract.
What we’d tell someone about to do this
If a feature needs OS-level primitives — foreground services, native notification actions, background execution guarantees — budget for hand-rolled platform channels from the start rather than losing a day to a plugin that almost fits. Define the message contract as a shared, versioned schema (even a hand-maintained one) rather than matching string literals by eye across two languages. And put an explicit acknowledgment or heartbeat on any long-running native-to-Dart stream, because “the stream will just work” is the assumption that costs you a debugging session when the engine gets torn down mid-scan.
This is the kind of boundary work that doesn’t show up in a feature list but determines whether an app survives contact with real devices and real OS memory pressure. It’s also the part of mobile development we spend the most engineering time on at orithLabs — not because it’s glamorous, but because Crumb Count and the rest of what we’ve shipped only stayed reliable once we stopped trusting the abstraction and started reading the marshaled bytes ourselves.