← Back to posts Cover image for Flutter and Native Code: When to Bridge and When to Rewrite

Flutter and Native Code: When to Bridge and When to Rewrite

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

So you’ve built a great Flutter app, but now you need to do something that feels… platform-specific. Maybe it’s a complex camera operation, low-latency audio processing, or a deep integration with a system service. You’ve heard about Flutter’s platform channels for bridging to native code, but you’ve also heard whispers that sometimes you just have to rewrite the whole thing natively. How do you decide?

Let’s break down the decision-making process. The core question isn’t just about technical feasibility—it’s about development efficiency, performance, and long-term maintenance.

The Power of the Bridge: Platform Channels

Flutter’s platform channels are your primary tool for tapping into native capabilities. They allow your Dart code to send messages to the native side (Kotlin/Java for Android, Swift/Objective-C for iOS) and receive messages back. This is perfect for:

  • Accessing platform APIs not yet covered by a plugin (like a specific sensor).
  • Integrating with a proprietary native SDK.
  • Performing a one-off, complex calculation that’s already optimized in a native library.

Here’s a simple, practical example. Let’s say you need to get the device’s internal storage path, which isn’t directly available via path_provider. You’d create a method channel.

Dart Side (main.dart):

import 'package:flutter/services.dart';

class NativeStorageHelper {
  static const MethodChannel _channel =
      MethodChannel('com.example.app/storage');

  static Future<String?> getInternalStoragePath() async {
    try {
      final String? path = await _channel.invokeMethod('getInternalStoragePath');
      return path;
    } on PlatformException catch (e) {
      print("Failed to get path: '${e.message}'.");
      return null;
    }
  }
}

// Usage
void fetchPath() async {
  String? nativePath = await NativeStorageHelper.getInternalStoragePath();
  print('Native Path: $nativePath');
}

Android Side (MainActivity.kt):

import android.os.Environment
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.example.app/storage"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            if (call.method == "getInternalStoragePath") {
                val path = applicationContext.filesDir.absolutePath
                result.success(path)
            } else {
                result.notImplemented()
            }
        }
    }
}

This pattern is incredibly powerful for extending Flutter’s reach. For many needs, a well-crafted bridge is all you require.

When the Bridge Starts to Crumble

However, platform channels come with overhead. Every call is an asynchronous message pass. For operations that require high-frequency, low-latency communication—think real-time video filtering, continuous sensor data streaming at 60Hz, or complex gesture handling in a custom view—this overhead can become a performance bottleneck. The architecture can also get messy if your app’s core, performance-critical functionality is entirely native, with Flutter just acting as a UI shell.

Common signs a bridge might not be enough:

  1. High-Frequency Calls: You need to call a native function dozens of times per second. The channel latency will be noticeable.
  2. Complex Native UI: You need to embed a sophisticated, interactive native view (like a Google Map or a custom camera preview) that is the centerpiece of your app, not just a component.
  3. Platform-Specific App Flow: Your app’s logic and navigation differ drastically between platforms. Maintaining a single Flutter flow with complex conditional logic becomes harder than having two separate codebases.

The Rewrite Consideration

A full native rewrite is a significant undertaking. It means maintaining two (or three) separate codebases. You should only consider it when the core value proposition of your app is something Flutter inherently struggles with.

Scenarios where a rewrite may be the only viable option:

  • Advanced Camera Applications: Apps requiring direct control over camera hardware, multi-sensor processing, or custom image/video pipelines often need the granular control and performance only the native camera APIs provide.
  • Heavy System Integration: Apps that function more like system utilities (e.g., a custom launcher, a deep clipboard manager, a battery optimizer) require deep, continuous integration with the OS that can be cumbersome to manage through channels.
  • Performance-Critical Games/Engines: While Flutter is excellent for UI, a 3D game or a physics simulation engine is better built with a framework designed for that purpose (Unity, Unreal, or native OpenGL/Metal/Vulkan).

Practical Decision Framework

Ask yourself these questions:

  1. What is the performance profile? Is the native interaction a one-time call, occasional, or constant? Constant, high-frequency needs push you towards native.
  2. Where does the complexity lie? If the complex part is the business logic and UI, Flutter excels. If the complex part is the platform interaction itself, a bridge might suffice, but a rewrite could be cleaner.
  3. What’s the team’s expertise? Having strong native developers makes a bridge or rewrite easier. A Flutter-only team should lean heavily on plugins and minimize custom native code.
  4. Can you start with a bridge and pivot? Often, the best strategy is to prototype the complex feature using platform channels. If you hit an insurmountable performance wall or complexity barrier, you have concrete data to justify isolating that module or even a rewrite. You haven’t lost your entire Flutter investment.

The Balanced Approach: Hybrid Modules

You don’t always have a binary choice. The most pragmatic path is often a hybrid: a Flutter app where most of the UI and logic lives in Dart, and one or two performance-critical, platform-specific features are implemented as native modules (using platform channels or FFI for C/C++ libraries).

In summary: Use platform channels aggressively to extend Flutter’s capabilities—they are your first and best tool. Consider a rewrite only when profiling proves that the bridge overhead fundamentally breaks your app’s core feature, or when the feature is so deeply native that Flutter would be fighting the platform every step of the way. Most apps live happily in the middle, combining Flutter’s cross-platform efficiency with targeted native power where it counts.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations

Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations

Many Flutter apps need to display content that changes based on user locale, but also comes from a backend (like Firebase). This post will explore best practices for fetching and integrating dynamic, localized content from a backend, ensuring a seamless user experience across different languages and regions without hardcoding translations.

Cover image for Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues

Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues

Developers frequently encounter cryptic type mismatch errors like 'The argument type X can't be assigned to the parameter type Y' or '_InternalLinkedHashMap has no instance method 'cast''. This post will demystify these common Flutter/Dart type errors, explain their root causes (e.g., conflicting imports, dynamic typing pitfalls, JSON deserialization issues), and provide practical solutions to diagnose and fix them, improving code robustness and reducing debugging time.