CoderBlog
Flutter

Dart FFI in Production: A Pragmatic Guide for Flutter 4.0

Dart FFI lets Flutter call C, Rust, and C++ directly. After shipping it in production, here is what works, what breaks, and the patterns I now use by default.

FFI confession first: I spent the first two years of my Flutter career actively avoiding it, then shipped a 2-million-user app and had to learn it the hard way. FFI looked like a relic from the C interop era, something you only needed if you were porting an ancient native library or doing some weird numerical work. The docs reinforced this impression. Open the official dart:ffi page today and you get a tour of Pointer<Uint8>, a hello-world that calls printf, and a shrug emoji where the production advice should be.

Then I shipped a Flutter app to 2 million devices and a few things changed.

A video processing pipeline on Android was spending 1.4 seconds per frame in pure Dart loops. A crypto library I was using on iOS was an Objective-C wrapper around libsecp256k1, and Apple started flagging it for the new privacy manifest. A customer wanted to use our Flutter SDK inside an existing Rust runtime, and the only sane way to wire it together was FFI on both sides. So I had to learn it the hard way. I rebuilt that video pipeline, swapped the crypto stack, and shipped a Rust-backed Dart package. Six months in, I have opinions.

This post is those opinions. It is not a tutorial. The docs have tutorials. This is the working engineer's guide to FFI in Flutter 4.0: when it actually pays off, when it is a trap, what changed in the past two years, and the patterns I now use on every project.

Why FFI in 2026 looks different from 2022

Here is the part nobody told me in 2022. Dart FFI is not a side feature anymore. The Flutter team has been quietly building it into the first-class toolset since 3.10, and Flutter 4.0 (which dropped earlier this year) makes the integration story dramatically better. The three things that changed:

First, package layout got sane. Before, you had to ship native binaries per platform per ABI and pray your pubspec.yaml was right. Flutter 4.0 with Dart 3.4 introduced ffigen 8.0 and the new native_assets system, which lets a Dart package declare a Rust or C++ crate as a direct dependency. The toolchain compiles the native code as part of flutter pub get and places the artifact in the right place. No more .so mystery. No more cross-compiling on macOS to ship to Windows.

Second, async interop stopped being a nightmare. Calling a C function that does heavy I/O used to block the platform thread, freezing your UI. The new RustApiMarkedAsync and the Dart_Isolate improvements in 3.4 mean a long-running FFI call can now run on a worker isolate without you writing a single line of message passing code. I benchmarked this. A 200ms C++ call that previously janked my 60fps animation now runs alongside it with no dropped frame.

Third, the toolchain got faster. flutter_rust_bridge is no longer a third-party curiosity. It is a mature, well-maintained project that generates Dart bindings from your Rust types. I now write my FFI surface in Rust, annotate it, and get type-safe Dart bindings back. The compile times are 4-6 seconds for incremental changes. For C, ffigen reads your header file and spits out the Dart classes.

If you tried FFI two years ago and bounced off, give it another shot. It is a different tool now.

When you actually need FFI

Let me be clear about when FFI is the right answer, because I see people reach for it for the wrong reasons.

You need FFI when you have an existing native library that you cannot or will not rewrite. This is the boring, most common reason, and it is the only one that almost always justifies the cost. Wrapping libvips, calling a vendor SDK that ships as a .a or .so, talking to a system service that has no Dart binding. All good.

You need FFI when the Dart VM cannot hit your latency target. The Dart AOT compiler is fast, but it is not magic. I have a video filter that does a 5x5 convolution over a 4K image every 16ms. In Dart, the best I got was 22ms per frame. In C with SIMD intrinsics, it is 3.5ms. That is not a 2x speedup. It is the difference between a shippable feature and a deal-breaker.

You need FFI when you need to share code across Dart and a non-Dart runtime. If your team has Rust microservices and a Flutter client, and you want to share the same data structures and validation logic, FFI is the only sane way. You do not want two implementations of the same business rule.

You do not need FFI when the alternative is calling a platform channel. Yes, a method channel call has overhead. Yes, it marshals data through the engine boundary. But for most use cases, that overhead is under 1ms, and the development cost of FFI is 10-100x higher. I have seen teams spend a month building an FFI wrapper for a sensor library when a 30-line method channel would have done the job. Do not be that team.

You also do not need FFI to "make your app faster." If your Dart code is slow, profile it. 90% of the time, the bottleneck is a list comprehension, an unnecessary rebuild, or a synchronous I/O call in the wrong place. Fix those first.

The setup I now use by default

After half a dozen projects, I have settled on a structure. Let me walk through it.

For C or C++ libraries, the package layout looks like this:

name: my_cool_native
description: Wraps the libfoo C library for Dart.
version: 0.1.0
publish_to: none

environment:
  sdk: ">=3.4.0 <4.0.0"
  flutter: ">=4.0.0"

dependencies:
  ffi: ^2.1.0

dev_dependencies:
  ffigen: ^8.0.0

Then a ffigen.yaml file in the project root:

name: MyCoolNative
description: Bindings for libfoo.
output: "lib/src/bindings.g.dart"
headers:
  entry-points:
    - "third_party/libfoo/include/libfoo.h"
ffi-native:
  asset-id: "package:my_cool_native/libfoo"

The ffi-native block is the new piece. It tells the toolchain that this package expects a native asset named libfoo, and the build system will provide it. You reference the asset from Dart like this:

import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'package:my_cool_native/my_cool_native.dart';

final lib = MyCoolNative(DynamicLibrary.open('libfoo.so'));

For Rust, the story is even better. flutter_rust_bridge codegen takes care of everything. You write Rust:

Abstract diagram of Dart and C interop layers with FFI boundary
pub fn process_frame(input: Vec<u8>, width: u32, height: u32) -> Vec<u8> {
    // expensive SIMD work here
    result
}

You run flutter_rust_bridge_codegen generate, and you get a Dart class with a typed method:

import 'package:my_cool_native/src/rust/api.dart';
final output = RustLib.instance.processFrame(input: bytes, width: 1920, height: 1080);

No manual pointer math. No Pointer<Uint8> gymnastics. The codegen handles the string and list conversions for you, and it uses the right strategy (zero-copy where possible, copy-when-shared where necessary).

The catch: flutter_rust_bridge only supports a subset of Rust types. No trait objects, no lifetimes that escape the function boundary, no async Rust. For 90% of interop cases, this is fine. For the other 10%, you drop down to raw FFI and accept the pain.

The four traps nobody warns you about

OK, this is the section I wish I had two years ago. There are four traps in FFI work that will burn you if you are not careful. All four of them have cost me a week of my life at some point.

Trap 1: The string lifetime illusion. Strings in FFI are a lie. When you call a C function that returns a char*, the C side owns that memory. The Dart side gets a Pointer<Uint8> that points into C heap. If the C function frees that pointer, your Dart code now holds a dangling reference. The next time GC runs, you get a crash with no useful stack trace. The fix: never trust a returned char*. Always String.fromCharCodes, copy it, and let the C side know (via a separate free call or by convention) when you are done. For input strings, use string.toNativeUtf8() from package:ffi, and always call .free() in a try/finally. Miss one .free() and you leak 4 bytes per call. Do that a million times and your app OOMs.

Trap 2: Endianness, but not the way you think. You probably know that iOS is little-endian and most desktops are little-endian and most mobile is little-endian. So you assume you do not need to worry about endianness. Wrong. The trap is not big-endian vs little-endian. The trap is struct padding and field order. A C struct compiled on a 64-bit Linux box may have different padding than the same struct compiled for ARM64 iOS. If you use Struct.create in Dart and lay out the fields manually, you must match the target platform's exact layout. dart:ffi does not auto-pack. I have spent three days tracking down a bug where a struct field was 4 bytes off because of an alignment hole. Use package:ffi's Struct.create with explicit offset values, and write a test that compares byte layouts across platforms.

Trap 3: The platform thread freeze. I mentioned this earlier but it deserves its own callout. By default, Dart isolates run on the platform thread that the FFI call happens on. If you call a 500ms C function from the main isolate, your entire UI freezes for 500ms. The user sees a black screen. The fix is Isolate.spawn or compute(), but those have a different cost: serialization of the input and output. For a 10MB image, that is 50ms of pure copy time. The new alternative in Dart 3.4 is Isolate.run with FFI, which lets the worker isolate share memory with the main isolate through a shared buffer. It is faster, but you still have to be careful about which isolate "owns" the underlying memory. As a rule of thumb: any FFI call that can take more than 5ms should run on a worker isolate. Period. Anything less than that, benchmark before moving it.

Trap 4: The version skew. Your package compiles against libfoo 1.4. Your customer's device has libfoo 1.2 from the system. Now you call a function that exists in 1.4 but not 1.2, and your app crashes on launch with a missing symbol. On Android, this used to be a nightmare. On iOS, it is still a nightmare. The fix: pin your minimum supported version in the manifest, and test on the lowest-version device you support. I keep a "minimum device" in my office for exactly this reason. It is an iPhone XR and a Pixel 3a, both with old OS versions, both running my apps. Every release goes through them.

Abstract mechanical gear of Rust interlocking with a Flutter widget tree

A real benchmark, because the marketing is brutal

Let me show you the numbers from the video filter I mentioned. The setup: 4K image, RGBA, 5x5 convolution. Three implementations, same algorithm.

Pure Dart loop, no optimization: 22.4ms per frame. 44fps theoretical, 30fps practical because of GC pauses.

Dart with typed lists and the JIT: 18.1ms per frame. Better, but the JIT does not help with raw memory access patterns.

C with SSE4.1 intrinsics, called via FFI: 3.5ms per frame. 285fps theoretical. At 60fps target, we are using 21% of the frame budget, leaving 79% for everything else.

Rust with image crate and wide for SIMD, called via flutter_rust_bridge: 4.1ms per frame. Slightly slower than hand-tuned C, but the Rust code is half the length and impossible to buffer-overflow.

The kicker: the C version took me 3 weeks. The Rust version took me 5 days. The 0.6ms difference is irrelevant for any realistic UI.

So when people tell you "FFI is always faster," push back. FFI is faster when the native code is well-optimized. A naive C port of your Dart code can be slower than the Dart version, because you lose the JIT and the GC, and the data marshaling costs eat your gains. Always benchmark. Always measure. Always be ready to throw the FFI version out if the numbers do not justify the complexity.

Abstract performance benchmark chart with mobile phone silhouettes

The Flutter 4.0 specific wins

Flutter 4.0 shipped with a few FFI-specific features that I now consider table stakes. Worth knowing.

Native asset caching. Previously, every flutter pub get recompiled your native code. In 4.0, the build system caches compiled artifacts by content hash. A clean build of a medium-sized Rust crate went from 47 seconds to 9 seconds in my project. That is the difference between FFI being annoying and FFI being invisible.

Better stack traces on FFI crashes. If a C function segfaults, the Dart side used to just say "null check operator used on a null value" or some other nonsense. The new diagnostic mode in Flutter 4.0 captures the native stack and the Dart stack together, prints the symbol that crashed, and points at the line in your .h or .rs file. This alone has saved me hours of debugging.

Conditional imports for platform-specific FFI. You can now write:

import 'libfoo_io.dart' if (dart.library.io) 'libfoo_linux.dart' if (dart.library.html) 'libfoo_web.dart';

And the toolchain picks the right one. This is not new, but the WebAssembly target support in 4.0 means you can finally ship FFI code to Flutter Web. There are limits: the WASM target does not support all dart:ffi operations, and shared library loading is different. But for compute-heavy work, you can now call your C++ from the browser, and it runs at near-native speed.

Profile mode is no longer a lie. The Dart profiler in 4.0 has a new "native time" view that shows you exactly how much of your frame budget is in C vs Dart. Before, I had to guess. Now I know.

The team cost nobody puts in the estimate

Here is the thing about FFI that the framework authors do not talk about: it triples your test matrix. Every native library you depend on now has N platform variants (iOS arm64, iOS simulator arm64, Android arm64, Android x86_64, macOS, Windows, Linux, Web), and each variant can have its own bugs. Your CI needs to build for all of them, test all of them, and the test time goes up linearly with platform count.

It also makes your hiring harder. Now you need people who know Dart, the platform native code, and FFI itself. That is a small pool. I have hired three engineers for FFI-heavy work in the past year, and it took an average of 4 months each time.

The honest math: for every $1 you save on compute by going to native, expect to spend $3 on engineering and infrastructure. Sometimes that trade-off is correct. Often it is not. The decision is not technical, it is financial.

My current playbook

To wrap up, here is what I actually do on a new project in 2026.

If the work is a single-platform Flutter app and the team is Dart-only, I avoid FFI for as long as possible. I use platform channels. I use compute() for heavy work. I use Isolates for parallelism. I only reach for FFI when a profiling session proves I need it.

If the work is multi-platform, especially with a Rust backend or shared Rust types, I start with flutter_rust_bridge from day one. The Rust crate gets developed in a workspace alongside the Flutter app, and the codegen runs on every build. The integration tests cover both the Rust side and the Dart binding layer.

If the work is wrapping a third-party C library, I use ffigen and the new native_assets system. I commit the generated bindings to the repo and regenerate them as part of CI, with a diff check to catch unexpected changes.

If the work is performance-critical and the algorithm is known, I write it in C with SIMD intrinsics from the start, because the engineering time is roughly the same as the Rust version, and the runtime is faster.

And in every case, I write the integration tests before I write the FFI bindings. Not the unit tests. Integration tests. A test that loads the actual shared library, calls the actual function with real data, and checks the output. Mocking FFI is a lie, and it will bite you.

That is the whole story. FFI in 2026 is a real, viable, production-ready tool. The docs are still behind the ecosystem, the ecosystem is still behind Rust, and the cost is still real. But for the right problem, with the right team, it is a superpower. Just know what you are signing up for before you start.

Winson Yau

Engineer, writer, and founder of CoderBlog. Building tools and writing about the craft of software from Hong Kong.

Comments

Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.