When to Write Native Code in a Flutter App (and When Not To)
A practical guide to when to write native code in a Flutter app: judging plugins, wrapping native SDKs, platform views, widgets, extensions, CarPlay and background work.

Flutter’s pitch is one codebase. The fine print is that some features live outside Flutter’s world entirely, and for those you’re writing Kotlin and Swift whether you planned to or not.
I write native Android in Kotlin and Java as well as Flutter, so I’m not scared of a .kt file. But I’ve also seen Flutter projects where someone reached for native code far too early, and ended up maintaining three codebases instead of one. The skill isn’t writing platform code. It’s knowing when you actually need to.
Here’s how I make that call.
Start with the question: does a good plugin already exist?
Most “I need native code” moments are really “I need a plugin” moments. Camera, biometrics, secure storage, maps, in-app purchases: someone has already done the platform work, and often done it well.
“A plugin exists” isn’t the same as “a plugin I’d ship”, though. Before adding one, I check:
- Pub points and the publisher. A low score usually means missing documentation, platform support gaps or analysis issues. A verified publisher, especially the Flutter team, Firebase or the SDK vendor itself, is a good sign.
- Recent activity. When was the last release? Does it support the current Flutter and Dart versions? A plugin that hasn’t been touched in two years will block your next Flutter upgrade.
- The issue tracker. Not the number of open issues, which grows with popularity, but whether maintainers respond, and whether there are unanswered crash reports on the platform you care most about.
- The native code itself. Open the
android/andios/folders. If it’s a thin, readable wrapper, you can fork and fix it if you ever have to. If it’s thousands of lines nobody has touched in years, that’s a risk you’re taking on.
If a plugin passes, use it. Writing your own version of a solid plugin is a cost with no user-visible benefit.
When native code is the right call
Even with a healthy plugin ecosystem, these situations keep coming up.
1. Wrapping a vendor SDK that has no Flutter support
Payment terminals, hardware accessories, niche analytics, a client’s in-house SDK. If the vendor only ships an Android AAR and an iOS framework, you’re writing the bridge.
This is the most common case and also the most manageable. Keep the bridge thin: expose exactly the handful of calls your app uses, convert native types to simple values, and keep all business logic in Dart. For anything more than a couple of methods, I use Pigeon to generate type-safe interfaces on both sides instead of hand-writing string-keyed method channels. More on that in platform channels and Pigeon in Flutter.
For a single call, a plain MethodChannel is fine:
const _channel = MethodChannel('app/device_info');
Future<String?> fetchInstallSource() {
return _channel.invokeMethod<String>('installSource');
}
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "app/device_info")
.setMethodCallHandler { call, result ->
when (call.method) {
"installSource" -> result.success(readInstallSource())
else -> result.notImplemented()
}
}
}
}
2. Native UI inside Flutter: platform views
Sometimes you need a real native view on screen: a map, a web view, a video player, an ad. Flutter supports this through platform views (AndroidView and UiKitView, usually hidden behind a plugin).
They work, but they’re not free. Flutter has to composite a native view with its own rendering, which costs performance and sometimes brings odd behavior around scrolling, gestures, keyboard focus and transparency. Different Android composition modes trade fidelity against speed, and the right one depends on the content.
My rule: platform views are fine for one large, mostly static element, like a full-screen map. I avoid putting them inside scrolling lists or animating them. If you need a dozen small native views on one screen, rethink the design. And profile on a mid-range Android device, not your flagship phone.
3. Things that run outside your app
This is where Flutter simply doesn’t reach:
- Home screen widgets are built with SwiftUI and WidgetKit on iOS and with Android’s app widget APIs (or Jetpack Glance) on Android. Packages like
home_widgethelp you share data between the Flutter app and the widget, but the widget UI is native. - Share extensions and other iOS app extensions run as separate processes with tight memory limits. Again, packages help with the plumbing, but the extension target is Swift.
- Watch apps, notification content extensions, App Clips follow the same pattern.
The design that works: the Flutter app is the source of truth, it writes a small amount of data to shared storage (an App Group on iOS, shared preferences or a file on Android), and the native extension reads it and renders. The native side stays tiny and dumb.
4. CarPlay and Android Auto
When I built in-car support for Nightingale, Flutter’s “one UI for all platforms” idea didn’t apply. CarPlay and Android Auto don’t render your app’s UI at all. They give you templates and you fill them with data, through native APIs on each platform. I wrote up how that fits with a Flutter app in Apple CarPlay and Android Auto with Flutter.
The lesson that applies more broadly: keep Flutter as the brain, let native code handle presentation for the surfaces Flutter can’t draw on.
5. Long-running background work
Background execution is controlled by the OS, and the APIs are platform-specific: Android foreground services with a visible notification, iOS background modes for audio, location or VoIP. Packages cover the common cases well (workmanager, audio service packages, location plugins). When your needs fall outside them, for example a foreground service type that no plugin exposes, you’ll be writing Kotlin. The full picture is in background work in Flutter.
A quick decision table
| Situation | My default |
|---|---|
| Healthy, maintained plugin exists | Use it |
| Plugin exists but abandoned, small native surface | Fork it, or write a thin bridge |
| Vendor SDK with no Flutter support | Thin bridge with Pigeon |
| One large native view (map, video) | Platform view, profile on mid-range Android |
| Home screen widget, share extension | Native target, data shared from Flutter |
| CarPlay / Android Auto | Native templates, Flutter as the brain |
| “Native would be faster” for normal UI | Profile first. It almost never is the fix. |
That last row deserves emphasis. Rewriting a slow Flutter screen in native code is rarely the answer. The slowness is almost always an expensive rebuild, an oversized image or work on the UI thread, and it’s fixable in Dart.
Keeping native code small and tested
Once you’ve decided you need native code, the goal is to need as little of it as possible.
One boundary, one Dart interface. Wrap every native call behind an abstract Dart class. The rest of the app depends on the interface, never on a channel name. That also lets you swap in a fake for tests and for platforms you don’t support yet.
Simple types across the boundary. Strings, numbers, booleans, lists and maps, or Pigeon-generated data classes. Never let native objects leak into Dart, and keep logic like validation and formatting on the Dart side, where it’s written once and tested once.
Test both sides. On the Dart side, you can mock a channel in widget and unit tests:
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('app/device_info'),
(call) async => call.method == 'installSource' ? 'play_store' : null,
);
On the native side, if the bridge does any real work, give it ordinary JUnit or XCTest tests, run them in CI, and add a small integration test that drives the real channel on a device. Native code that’s only exercised by manually tapping through the app will break quietly during an upgrade.
Document it. A short README in the native folder: what it does, which SDK version it wraps, how to run its tests. The next Flutter developer on the project may not know Swift, and they’ll thank you.
Takeaways
- Check plugin quality properly (pub points, publisher, recent releases, issue responses, the native source) before writing your own.
- Native code is justified for vendor SDKs, app extensions and widgets, CarPlay and Android Auto, and some background work.
- Platform views have real costs. Use them for single, large elements and profile on mid-range hardware.
- Keep native bridges thin, typed and behind a Dart interface, with logic staying in Dart.
- Test both sides of the bridge, and don’t reach for native code to fix ordinary UI performance.
If your Flutter app needs a native integration and you want it done without turning into a second codebase, let’s talk.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).