We’ve pulled up a Flutter codebase with zero analyzer warnings, full sound null safety, migration to 2.12+ done years ago, and still found a null-pointer-shaped crash sitting in production. Not a hypothetical. It was a MethodChannel call returning a platform value that Dart’s type system was fully convinced could never be null, because Dart’s type system has no visibility past the channel boundary. The `!` operator was doing exactly what it was told. The problem is what it was told.

This is the gap teams miss after a null safety migration: the compiler enforces soundness within Dart, but the moment a value crosses into native Swift or Kotlin and comes back, that guarantee is a promise you made yourself, not one the compiler checked. Everyone treats “flutter analyze is clean” as the finish line. It’s the finish line for one layer of the app.

Where the boundary actually breaks

Three patterns account for most of what we find when auditing platform channel code:

  • Optional native return values cast as non-null. A `MethodChannel.invokeMethod(‘getDeviceId’)` returns `Future` from the SDK, but the call site does `.invokeMethod(‘getDeviceId’) as String` or immediately calls `.then((v) => v!)`. On most devices, on most runs, it works. Then a permission gets revoked mid-session, or the native side hits an edge case and returns `null` through the channel’s standard codec instead of throwing, and the app dies with a null check operator used on a null value — with a stack trace that points at Dart code that “already handles null safety.”
  • Silent type coercion through the standard codec. The `StandardMethodCodec` doesn’t validate against your Dart-side generic type parameter — it’s an unchecked cast at the FFI boundary. If native code sends `null` where Dart expects `Map`, or sends an `Int32` where Dart expects `String`, you get a `PlatformException` or a null on the Dart side depending on the codec path, and it surfaces at the `invokeMethod` call site, often several frames of async gap away from where a stack trace actually helps you.
  • Kotlin/Swift nullability not mirrored in the channel contract. Kotlin’s `String?` and Swift’s `String?` map cleanly to Dart’s `String?` in principle. In practice, we regularly find native handlers that declare a non-optional return type but have a code path — an early return, an uninitialized `var`, a force-unwrap that itself should have failed first — that produces `null` anyway at the platform layer, because Kotlin and Swift’s own null safety is enforced by their compilers, not by the channel serialization step.

None of these show up in `flutter analyze`, because analyze only sees the Dart-side declared type — which is usually the developer’s optimistic annotation, not a verified contract with the native implementation.

A concrete shape

The most common version we see: a plugin method channel wrapper written early in a project, before the team had settled on error handling conventions, that looks roughly like this:

Future<UserProfile> fetchNativeProfile() async {
  final result = await _channel.invokeMapMethod<String, dynamic>('getProfile');
  return UserProfile.fromMap(result!);
}

This compiles clean. It passes review, because reviewers are scanning for Dart-side null handling and this looks fine — `result!` reads as “we already checked.” Nobody checked. The native side returns null when the OS-level profile store is mid-sync, which happens rarely enough that it doesn’t show up in a two-week QA cycle but reliably shows up at scale. `UserProfile.fromMap(result!)` becomes a top crash in whatever crash reporting the team eventually wires up, usually months later, usually attributed vaguely to “flaky native issue” because nobody traces it back to a one-line assumption baked in during initial scaffolding.

What an audit actually checks

When we run a code audit that includes platform channel surfaces, we don’t start from the Dart type annotations — we start from the native implementation and work backward, because the native side is the actual source of truth for what can be null. Concretely:

  1. Every `invokeMethod` call site gets cross-referenced against its native handler’s actual return paths, not its declared type — including early returns, exception-to-null conversions, and any `try/catch` on the native side that swallows an error and returns nil/null instead of propagating a `PlatformException`.
  2. Every force-unwrap (`!`) immediately downstream of a channel call gets flagged for manual review, regardless of what the analyzer says, because this is precisely the pattern where Dart’s soundness guarantee is being asserted rather than verified.
  3. We check whether the codec in use (standard vs. a custom `MessageCodec`) actually validates types at the boundary, or just deserializes optimistically and defers the failure to whatever touches the value first in Dart.
  4. We look for asymmetry between platforms — a channel method that’s genuinely non-nullable on iOS because of how the native SDK is structured, but nullable on Android for the same call, sharing one Dart-side type declaration.

The fix is rarely complicated once found — it’s usually adding an explicit null check with a real fallback or error path at the boundary, not deeper in the app where the crash actually surfaces. The expensive part is finding it, because it’s invisible to every automated tool that only reasons about the Dart side of the boundary.

This is the kind of failure mode we look for specifically in the code audits we run at orithLabs — not just linting and static analysis, but tracing actual data flow across the platform channel boundary in apps like Crumb Count and Kompete, where native integration isn’t incidental to the product. If your team has shipped a Flutter app with meaningful platform channel surface area and hasn’t had someone trace that boundary explicitly, it’s worth a look before it finds you in production.