Can Rust and Flutter Work Together?
Yes, through FFI, and increasingly through flutter_rust_bridge. What the boundary costs, where it genuinely pays off, and when it is an expensive way to avoid writing Dart.
The short answer is yes, and it has become practical rather than a
proof of concept. Dart's FFI is stable, flutter_rust_bridge generates the glue
that used to be the hard part, and several production apps ship Rust cores behind
Flutter interfaces today.
The longer answer is that "can" and "should" are different questions, and the gap between them is where most of the interesting detail lives. A Rust core buys you real things, speed, memory safety, and code shared with a backend or a desktop app. It costs you a build pipeline, a debugging story that spans two languages, and a boundary that is easy to accidentally make the bottleneck.
I have been learning Rust partly to answer this properly. Here is where I have landed.
Glossary
Everything this post uses, defined before it is used. Skip it if the terms are already familiar, or come back when one of them trips you up.
The one sentence the rest of this post expands: Dart can call compiled Rust functions directly, which is fast and genuinely useful, and it adds a second language and a second build pipeline to your project.
Terms
| Term | Meaning |
|---|---|
| Foreign function interface | A way for code in one language to call compiled code written in another. |
| C ABI | The oldest common calling convention. Almost every language can speak it, so it is the meeting point. |
| Dynamic library | A compiled file of machine code the app loads at runtime, like libcore.so. |
| Pointer | A memory address. Passing one avoids copying data, and nothing checks it for you. |
unsafe | Rust code the compiler cannot prove is memory-safe. You are asserting it instead. |
| Serialisation | Turning data into bytes to send somewhere. FFI skips it, which is why it is fast. |
| Platform channel | Flutter's message-passing route to native code. Asynchronous and serialised, unlike FFI. |
| Boundary | The line where Dart calls Rust. Cheap per call, expensive if you cross it in a loop. |
| Chatty API | A design that crosses that boundary many times with small payloads. The pattern to avoid. |
| Opaque handle | A reference Dart holds to an object that stays on the Rust side, so state is not copied back and forth. |
| Isolate | Dart's unit of execution with its own memory. The UI runs in one. |
| Worker thread pool | Background threads the bridge runs Rust calls on, so the UI isolate is not blocked. |
| Crate | A Rust package. Cargo is Rust's build tool and package manager. |
cargokit | The glue that makes Gradle and Xcode build your Rust as part of flutter build. |
| Cross-compilation | Building on your machine for a different processor and platform. |
| Target ABI | One specific processor and platform combination, like aarch64-linux-android. |
| Generated bindings | Dart files the bridge writes for you. Committed, never hand-edited, like .g.dart files. |
Result and Option | Rust's types for "this may have failed" and "this may be absent", both checked by the compiler. |
| Panic | Rust's version of an unrecoverable error, roughly an uncaught exception. |
| Bus factor | How many people would have to leave before nobody understands a part of the codebase. |
Abbreviations
| Short | Full form | In plain words |
|---|---|---|
| FFI | Foreign Function Interface | Calling compiled code from another language directly |
| ABI | Application Binary Interface | The agreed rules for how compiled functions pass arguments |
| API | Application Programming Interface | The set of functions one piece of code offers another |
| UI | User Interface | Everything the user sees and touches |
| CLI | Command Line Interface | A terminal tool with no graphical interface |
| CI | Continuous Integration | The server that builds and tests every change |
| GC | Garbage Collector | The thing that frees memory for you. Rust has none, so FFI memory is your job |
| AOT | Ahead Of Time | Compiled before shipping, into fixed native machine code |
| JSON | JavaScript Object Notation | A common text format for data |
| ML | Machine Learning | Model inference and the data preparation around it |
| DSP | Digital Signal Processing | Number-crunching over audio or sensor data |
| SIMD | Single Instruction, Multiple Data | CPU instructions that process several numbers at once |
| CRDT | Conflict-free Replicated Data Type | A data structure that merges edits from several devices without a central referee |
| NDK | Native Development Kit | Android's toolchain for building native code |
| LTO | Link Time Optimisation | A build setting that shrinks and speeds up the final binary |
| FAQ | Frequently Asked Questions | The question section near the end |
How the two actually talk
Before the mechanism, the shape of it. Your Rust is compiled into a file of machine code that ships inside the app, and Dart reaches into that file and calls a function in it. There is no server, no message, no waiting.
Everything here runs through dart:ffi, Dart's foreign function interface,
which calls C ABI functions directly with no serialisation and no message
passing.
That is the important architectural point. This is not a platform channel. There is no JSON, no async hop, no bridge in the React Native sense. A Dart function call becomes a native function call, and the overhead is measured in nanoseconds.
Raw FFI, by hand
Rust exposes a C-compatible function. Dart looks it up in the dynamic library and calls it.
The example below is deliberately the smallest useful one: hand Rust some bytes, get a number back. Read it for the amount of ceremony rather than the logic.
// src/lib.rs
#[no_mangle]
pub extern "C" fn checksum(ptr: *const u8, len: usize) -> u32 {
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
bytes.iter().fold(0u32, |acc, b| acc.wrapping_add(*b as u32))
}typedef _ChecksumC = Uint32 Function(Pointer<Uint8>, IntPtr);
typedef _ChecksumDart = int Function(Pointer<Uint8>, int);
final _lib = DynamicLibrary.open('libcore.so');
final _checksum = _lib.lookupFunction<_ChecksumC, _ChecksumDart>('checksum');
int checksumOf(Uint8List data) {
final ptr = malloc<Uint8>(data.length);
ptr.asTypedList(data.length).setAll(0, data);
try {
return _checksum(ptr, data.length);
} finally {
malloc.free(ptr); // Dart's GC will not do this for you
}
}Look at what that small example already demands: two typedefs per function,
manual allocation, a try/finally to avoid leaking, and unsafe on the Rust
side to reconstruct the slice. Now imagine it for a struct with a nested list,
or a function returning a string.
Doing this by hand is fine for three functions and untenable for thirty. That is precisely the problem the tooling solves.
flutter_rust_bridge for anything real
flutter_rust_bridge reads your Rust and generates the Dart API, the FFI
plumbing, and the type conversions, including structs, enums, Option,
Result, Vec, and streams.
pub struct ParsedDoc {
pub title: String,
pub word_count: u32,
pub headings: Vec<String>,
}
pub fn parse_document(source: String) -> Result<ParsedDoc, String> {
// ordinary Rust - no unsafe, no pointers
}// generated; called like any Dart function
final doc = await parseDocument(source: text);
print('${doc.title} - ${doc.wordCount} words');Two things it gives you that matter more than the ergonomics.
It runs Rust calls on a worker thread pool by default, so a long computation does not block the UI isolate. You get the isolate benefit without writing isolate code, and without the message-copy dance.
It maps Result<T, E> onto Dart exceptions, so Rust's error handling arrives
as something Dart code can catch normally rather than as a sentinel value you
must remember to check.
Version 2 also supports passing opaque Rust objects by reference, so a long-lived Rust struct, a parser, a database handle, an engine, can live on the Rust side while Dart holds a handle rather than copying state back and forth.
Where it really pays off
The boundary has a cost. These are the cases where it clearly earns it.
Heavy computation
Anything CPU-bound where Dart's performance ceiling is the constraint: image and video processing, cryptography, compression, audio DSP, geospatial maths, parsing large binary formats, on-device ML pre- and post-processing.
Rust is typically several times faster than AOT Dart on tight numeric loops, and the gap widens with SIMD and careful memory layout. If a function is the measured bottleneck and it is pure computation, moving it is a real win.
The word doing the work there is measured. Rewriting a function that takes 3 ms is not a performance strategy.
Sharing a core across platforms
This is the strongest argument, and it is not about speed at all.
If you have a sync engine, a rules engine, a CRDT implementation, or a protocol parser that must behave identically across iOS, Android, desktop, web, and a server, writing it once in Rust and binding it everywhere removes an entire class of bug, the one where two implementations of the same logic drift apart and produce different results on different platforms.
Dart can be shared across Flutter targets, but not with a Rust backend, a CLI tool, or someone else's native app. Rust reaches further.
Reusing an existing library
Much of the best systems software is Rust now: ring for crypto, rusqlite,
image, regex, serde, whole codecs and ML runtimes. Binding one is usually
far cheaper than reimplementing it in Dart, and safer than binding the C
equivalent.
Going deeper: what the project actually looks like
The layout is unremarkable, which is the point, the Rust half is a normal Cargo crate that happens to be built by the app's build step:
my_app/
├── lib/ # Dart: UI, state, everything unchanged
│ └── src/rust/ # generated bindings - do not edit
├── rust/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # the public surface the bridge reads
│ └── parser.rs # ordinary Rust, no FFI awareness
└── flutter_rust_bridge.yaml
lib/src/rust/ is generated and belongs in the repo but never in a diff review.
Treat it the way you treat .g.dart files. rust/src/lib.rs is the only file
that defines the boundary. Everything beneath it is Rust that knows nothing
about Dart.
The build wiring is the part that will cost you an afternoon. cargokit hooks
Cargo into Gradle and Xcode so flutter build triggers the Rust
cross-compilation for each target ABI. When it works you forget it exists. When
it breaks you are reading linker output for a platform you were not thinking
about.
Going deeper: long-lived Rust objects, not just function calls
The version-one mental model is "call a Rust function, get a value back". That copies across the boundary each time, which is fine for a one-shot parse and wasteful for anything stateful.
Bridge v2 can hold a Rust object and hand Dart an opaque handle instead:
pub struct Engine {
index: SearchIndex,
}
impl Engine {
pub fn new(corpus: String) -> Engine { /*... */ }
pub fn query(&self, term: String) -> Vec<Hit> { /*... */ }
}final engine = await Engine.newInstance(corpus: text); // built once
final hits = await engine.query(term: "flutter"); // no re-copyThe index stays on the Rust side, and only the query and its results cross. This is the shape that makes a Rust core worth having: a small, hot boundary around a large, stateful thing, rather than a chatty API that spends its time serialising.
What it costs
Being fair means stating the price clearly, because it is not small.
Build complexity. You now cross-compile Rust for aarch64-apple-ios,
aarch64-linux-android, armv7, simulators, and every desktop target you ship.
That means Rust toolchains in CI, NDK configuration, correct linking per
platform, and a longer, more fragile build. cargokit and the bridge's tooling
handle most of it, until something breaks on one target and you are debugging
linker flags.
Two-language debugging. A crash inside Rust surfaces in Dart as a much less helpful failure. Stack traces do not cross the boundary cleanly, and diagnosing a problem means being competent in both languages and both toolchains.
Team constraints. Every contributor now needs at least reading fluency in Rust. On a small team, "the person who knows the Rust part" is a genuine bus factor.
Boundary overhead, if you are careless. The boundary behaves like a delivery run rather than a phone call. One van with a full load is efficient. A thousand vans each carrying one box is not, even though each van is quick. An individual FFI call is cheap, but crossing per item in a loop is not, and large payloads still get copied. The pattern that works is few calls with substantial payloads. The pattern that disappoints is a chatty API called thousands of times per frame.
Binary size. A Rust core adds to an already large Flutter binary. Usually
modest with LTO and opt-level = "z", but not zero.
Going deeper: when it is the wrong call
If the answer to "what would this Rust code do?" is business logic, CRUD, or API calls, keep it in Dart. You get hot reload, one toolchain, one debugger, and one language your whole team reads.
Rust is worth it when the code is computational, shared beyond Flutter, or already written. Reaching for it because Rust is more enjoyable to write is a real motivation and a poor engineering justification, worth admitting to yourself before you commit a team to it.
Key takeaways
- Yes, they work together, through
dart:ffi, with direct native calls and no serialisation bridge. - Do not hand-write FFI beyond a few functions.
flutter_rust_bridgegenerates the plumbing, including structs, enums,Result, and streams. - The bridge runs Rust off the UI isolate by default, so you get background execution without writing isolate code.
- The strongest case is a shared core, not raw speed, one implementation behaving identically across mobile, desktop, and server.
- The cost is a cross-compilation pipeline and two-language debugging. Budget for CI work and a bus factor.
- Keep the API coarse. Few calls with big payloads. Never a chatty boundary inside a loop.
- Business logic belongs in Dart. Reach for Rust when the work is computational, shared, or already written.
FAQ
Do I need to write unsafe Rust?
Almost never with flutter_rust_bridge, your Rust stays ordinary and safe, and
the generated layer handles the boundary. Hand-rolled FFI does require unsafe
wherever you reconstruct slices or strings from raw pointers.
Does this work on Flutter web?
Yes, via WebAssembly, and the bridge supports it, but the story is less mature than on native. Expect more friction around threading and binary size, and test that target specifically rather than assuming parity.
How does this compare to using C++ instead?
FFI treats them the same, since both expose a C ABI. Rust's advantage is memory safety and Cargo. C++ has broader existing codebases and an easier story if your team already knows it. The integration mechanics are equivalent.
How do I debug a panic in the Rust half?
Set panic = "abort" off in release so unwinding is possible, and install a
panic hook that logs before the process dies. The bridge converts a caught panic
into a Dart exception, but the message is far more useful if you have logged the
Rust backtrace first. Expect to reach for RUST_BACKTRACE=1 and native logs
rather than the Dart stack trace.
Can Rust call back into Dart?
Yes, the bridge supports streams from Rust to Dart, which covers progress reporting and event feeds. Arbitrary synchronous callbacks into Dart are more constrained. Design for Rust pushing events rather than Dart passing closures down.
What about hot reload?
It does not extend to Rust. Changing Rust means a rebuild, which is one of the sharpest day-to-day costs, you lose the fast loop precisely in the part of the codebase that is hardest to reason about.
Is this production-ready?
The mechanism is. dart:ffi is stable and flutter_rust_bridge is mature and
widely used. What is not solved is the operational overhead: CI, cross-compile
matrices, and debugging across the boundary. Those are engineering-time costs,
not correctness risks.
Conclusion
Rust and Flutter fit together better than the language gap suggests, a compiled, GC-free language behind a rendering framework that already owns its own pipeline is a coherent pairing, and the tooling has closed most of the ergonomic gap.
But the honest framing is not "Flutter plus Rust is faster". It is that you are adding a second language, a second toolchain, and a boundary to your project. If you have a computational core, a genuine need to share logic beyond Flutter, or a Rust library you would otherwise reimplement, that is a good trade. If you are reaching for it to make CRUD faster, you are buying complexity you will pay for every sprint and benefiting from none of it.
References
Official documentation for the topics covered here.
- Dart: C interop using dart:ffi -
dart:ffi, the mechanism everything here is built on - Dart API: dart:ffi - the FFI types themselves, pointers, structs and native function lookup
- pub.dev: flutter_rust_bridge - the generator that writes the binding layer, plus its build and threading model
- Rust: FFI (the Rustonomicon) - the Rust side of a C ABI boundary,
externfunctions andno_mangle - Rust: The Cargo Book - Cargo, crates and the cross-compilation targets each mobile ABI needs
- Rust: Error handling -
Result, panics, and why a panic must not cross the boundary - Android: NDK - the Android native toolchain your Rust targets get built against
Read more
The boundary discussion here extends Flutter Is Not Just a UI Framework, which covers channels, FFI, and platform views in general. For the Dart-side alternative to moving work off the UI thread, see Flutter Isolates Explained Through a Real Example.