NK

Search

Search pages, projects, posts, components, and icons

All articles
Mobile15 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.

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

TermMeaning
Foreign function interfaceA way for code in one language to call compiled code written in another.
C ABIThe oldest common calling convention. Almost every language can speak it, so it is the meeting point.
Dynamic libraryA compiled file of machine code the app loads at runtime, like libcore.so.
PointerA memory address. Passing one avoids copying data, and nothing checks it for you.
unsafeRust code the compiler cannot prove is memory-safe. You are asserting it instead.
SerialisationTurning data into bytes to send somewhere. FFI skips it, which is why it is fast.
Platform channelFlutter's message-passing route to native code. Asynchronous and serialised, unlike FFI.
BoundaryThe line where Dart calls Rust. Cheap per call, expensive if you cross it in a loop.
Chatty APIA design that crosses that boundary many times with small payloads. The pattern to avoid.
Opaque handleA reference Dart holds to an object that stays on the Rust side, so state is not copied back and forth.
IsolateDart's unit of execution with its own memory. The UI runs in one.
Worker thread poolBackground threads the bridge runs Rust calls on, so the UI isolate is not blocked.
CrateA Rust package. Cargo is Rust's build tool and package manager.
cargokitThe glue that makes Gradle and Xcode build your Rust as part of flutter build.
Cross-compilationBuilding on your machine for a different processor and platform.
Target ABIOne specific processor and platform combination, like aarch64-linux-android.
Generated bindingsDart files the bridge writes for you. Committed, never hand-edited, like .g.dart files.
Result and OptionRust's types for "this may have failed" and "this may be absent", both checked by the compiler.
PanicRust's version of an unrecoverable error, roughly an uncaught exception.
Bus factorHow many people would have to leave before nobody understands a part of the codebase.

Abbreviations

ShortFull formIn plain words
FFIForeign Function InterfaceCalling compiled code from another language directly
ABIApplication Binary InterfaceThe agreed rules for how compiled functions pass arguments
APIApplication Programming InterfaceThe set of functions one piece of code offers another
UIUser InterfaceEverything the user sees and touches
CLICommand Line InterfaceA terminal tool with no graphical interface
CIContinuous IntegrationThe server that builds and tests every change
GCGarbage CollectorThe thing that frees memory for you. Rust has none, so FFI memory is your job
AOTAhead Of TimeCompiled before shipping, into fixed native machine code
JSONJavaScript Object NotationA common text format for data
MLMachine LearningModel inference and the data preparation around it
DSPDigital Signal ProcessingNumber-crunching over audio or sensor data
SIMDSingle Instruction, Multiple DataCPU instructions that process several numbers at once
CRDTConflict-free Replicated Data TypeA data structure that merges edits from several devices without a central referee
NDKNative Development KitAndroid's toolchain for building native code
LTOLink Time OptimisationA build setting that shrinks and speeds up the final binary
FAQFrequently Asked QuestionsThe 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-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. 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_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.

References

Official documentation for the topics covered here.

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.