- Published on
Flutter Performance Part 5: Breaking Limits with Rust & FFI
- Authors

- Name
- Phat Tran
Four parts in, we have profiled the app, tamed rebuilds, moved heavy work off the main thread, and cleaned up memory and binary size. For 99% of apps, that is the whole journey. Dart's AOT compiler is genuinely fast, and most "Dart is too slow" complaints turn out to be one of the problems from parts 1 through 4 in disguise.
This part is for the other 1%: on-device cryptography for a custom security token, real-time image processing to verify an ID card from the camera feed, a local ML model. Workloads where you need C-level throughput, and Dart is honestly not the right tool.
For those, Flutter gives you FFI (Foreign Function Interface), and the modern pairing is FFI plus Rust.
1. The bottleneck FFI replaces: method channels
The classic way to reach native code from Flutter is a MethodChannel. It works by message passing: to hand a 10MB camera frame to Kotlin, you serialize it in Dart, push it across the platform channel, and deserialize it on the other side. The codec run costs real time, and the data exists twice in RAM while it happens.
For calling an occasional platform API, none of that matters. For a per-frame image pipeline, it is the whole ballgame.
2. What FFI actually buys you
FFI skips the channel entirely. Dart calls a C-compatible function directly, and both sides can read the same native memory. There is no codec and no per-call serialization; the call itself costs on the order of nanoseconds.
One honest caveat: "zero copy" has limits. Dart objects live in the Dart heap, so getting data into native memory usually still involves one copy into a native buffer (you will see it in the code below). What you eliminate is the codec machinery and the second copy on the far side, which is where method channels bleed.
Why Rust for the native side? You could write C or C++, but Rust gives you the same speed with memory safety at compile time. In a payments app, "fast but occasionally segfaults" is not a feature. Tools like flutter_rust_bridge generate the bindings for you, and can even run your Rust functions on their own thread pool and expose them to Dart as plain Futures. If you go that route, you may never need the manual pattern below.
3. Raw FFI, done safely
With Dart 3 you can bind a native symbol with the @Native annotation, and the runtime resolves it for you (via native assets, or a DynamicLibrary lookup on older setups).
There is one trap that catches nearly everyone: an FFI call is synchronous on the thread that makes it. Call a Rust function that takes 1.5 seconds from the UI thread, and you have a 1.5-second frozen app. Everything from Part 3 applies here, so we combine FFI with Isolate.run():
import 'dart:ffi';
import 'dart:isolate';
import 'package:ffi/ffi.dart';
// Dart 3+: binds directly to a symbol in your Rust library.
// The Rust function is exposed with a C ABI:
// int32_t process_secure_image(uint8_t* pixels, int32_t length);
<Int32 Function(Pointer<Uint8>, Int32)>()
external int process_secure_image(Pointer<Uint8> pixels, int length);
// Combining Isolate.run (no UI freeze) with an Arena (no native memory leak)
Future<int> processHeavyImageSecurely(List<int> dartPixels) async {
// Push the blocking native call onto a background isolate
return await Isolate.run(() {
// 'using' with an Arena frees every allocation when the scope exits.
return using((Arena arena) {
// 1. Allocate native memory, outside Dart's garbage collector
final Pointer<Uint8> nativePixels = arena.allocate<Uint8>(dartPixels.length);
// 2. One copy: move the Dart bytes into the native buffer (no codec involved)
nativePixels.asTypedList(dartPixels.length).setAll(0, dartPixels);
// 3. Call straight into Rust
return process_secure_image(nativePixels, dartPixels.length);
}); // <-- Arena calls free(nativePixels) here, whatever happens above
});
}
Native allocations are invisible to Dart's garbage collector, which is why the Arena matters as much as the isolate. Forget it, and you have reinvented the C memory leak inside a Flutter app.
4. When FFI is the wrong answer
Knowing when to skip FFI matters more than knowing how to write it.
Reach for it when the workload is genuinely compute-bound: image and video processing, heavy cryptography, local ML inference. It is also the right wrapper when your company already has a battle-tested core, say a risk-calculation engine in C++ shared across platforms, that would be reckless to rewrite in Dart.
Skip it everywhere else:
- Platform features (GPS, camera, push notifications) are OS SDK territory. That is what method channels and Pigeon are for; FFI gives you nothing there.
- Everyday work like parsing JSON or sorting a list is not worth it. Dart's AOT output is close enough to native for ordinary code, and the binding overhead plus a Rust toolchain in your CI is a real cost you pay forever. For small tasks, FFI can end up slower than the Dart it replaced.
Wrapping up the series
The order of this series was the point, not just the content. Profile before you optimize (Part 1). Fix the rebuilds the profiler shows you (Part 2). Move CPU work off the main thread (Part 3). Keep RAM and binary size under control (Part 4). And only when all of that is done and the numbers still are not good enough, reach for native code (Part 5).
Most apps retire happily at Part 4. If yours is the one that needs Part 5, you now know the price and the payoff.
Measure first, optimize later.