NK

Search

Search pages, posts, and components

All posts
Mobile10 min read

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.

flutterrustdartffiperformance

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.

How the two actually talk

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.

// 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.

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.

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-copy

The 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. 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.

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_bridge generates 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.

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.