Foreign Function Interface (FFI) bridges between Rust and Dart unlock native performance for compute-intensive workloads in Flutter apps—signal processing, cryptography, on-device ML inference—without JNI overhead on Android or Objective-C marshalling on iOS. But naive FFI designs introduce memory leaks, undefined behavior from panics crossing language boundaries, and serialization bottlenecks that negate the performance gains. This article walks through production-grade patterns for type-safe, zero-copy Rust ↔ Dart interop.

Why Rust for Flutter Native Modules

Dart's AOT compilation delivers respectable performance for UI and business logic, but Dart's garbage collector introduces unpredictable latency spikes—problematic for real-time audio or frame-perfect computer vision pipelines. Rust offers:

  • Deterministic memory management: No GC pauses; allocations are explicit and predictable.
  • Zero-cost abstractions: Iterators, closures, and trait dispatch compile to the same machine code as hand-written C.
  • Memory safety without runtime overhead: Borrow checker prevents use-after-free and data races at compile time.
  • Cross-platform consistency: One Rust crate compiles to ARM64, x86_64, WASM targets without platform-specific shims.

In a recent on-device LLM project, moving token sampling from Dart to Rust reduced P99 latency from 47ms to 8ms—critical for interactive chat interfaces.

FFI Fundamentals: The C ABI Boundary

Dart's dart:ffi library communicates with native code via C calling conventions. Rust functions exposed to Dart must:

  • Use #[no_mangle] to prevent name mangling.
  • Declare extern "C" to match C ABI.
  • Accept and return only C-compatible types: primitives, raw pointers, opaque handles.

A minimal bridge looks like:

// Rust
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}
// Dart
import 'dart:ffi';
typedef AddFunc = Int32 Function(Int32, Int32);
final dylib = DynamicLibrary.open('libbridge.so');
final add = dylib.lookupFunction('add');
print(add(2, 3)); // 5

This trivial example hides three production challenges: passing complex data structures, handling Rust panics, and managing lifetimes across the boundary.

Zero-Copy Data Transfer with Shared Memory

Serializing large buffers—audio samples, image frames, token embeddings—through JSON or MessagePack destroys performance. Instead, allocate memory on the Rust side, pass raw pointers to Dart, and operate on shared buffers.

For a 1920×1080 RGBA image (8.3 MB), serialization overhead can exceed 40ms. Direct pointer access eliminates this:

// Rust
#[repr(C)]
pub struct ImageBuffer {
    data: *mut u8,
    width: u32,
    height: u32,
    len: usize,
}

#[no_mangle]
pub extern "C" fn process_image(ptr: *const u8, width: u32, height: u32) -> *mut ImageBuffer {
    let input = unsafe { std::slice::from_raw_parts(ptr, (width * height * 4) as usize) };
    let mut output = Vec::with_capacity(input.len());
    // ... apply filter ...
    Box::into_raw(Box::new(ImageBuffer {
        data: output.as_mut_ptr(),
        width,
        height,
        len: output.len(),
    }))
}
// Dart
final result = processImage(inputPointer.cast(), 1920, 1080);
final outputData = result.ref.data.asTypedList(result.ref.len);

Critical: Dart must never free Rust-allocated memory, and vice versa. Expose explicit free_buffer functions to deallocate on the correct side.

Panic Safety: Catching Unwinding at the Boundary

Rust panics unwind the stack, but unwinding through FFI into Dart is undefined behavior—crashes on iOS, silent corruption on Android. Every FFI entry point must catch panics:

use std::panic;

#[no_mangle]
pub extern "C" fn safe_divide(a: i32, b: i32) -> i32 {
    match panic::catch_unwind(|| {
        if b == 0 { panic!("division by zero"); }
        a / b
    }) {
        Ok(result) => result,
        Err(_) => -1, // sentinel error value
    }
}

For richer error handling, return a Result-like struct:

#[repr(C)]
pub struct FfiResult {
    value: T,
    error_code: i32,
    error_msg: *const c_char,
}

Dart checks error_code and throws a typed exception. This pattern prevents silent failures while maintaining type safety.

Type-Safe Codegen with ffigen and Diplomat

Hand-writing FFI bindings is error-prone. package:ffigen generates Dart bindings from C headers, but Rust → C header generation requires cbindgen. The full pipeline:

  1. Annotate Rust structs/functions with #[repr(C)] and extern "C".
  2. Run cbindgen to produce bridge.h.
  3. Run ffigen on bridge.h to generate bridge.dart.

For complex APIs, Mozilla's Diplomat framework automates bidirectional codegen, including:

  • Automatic Box wrapping for opaque handles.
  • Lifetime annotations to prevent use-after-free.
  • Callback support for Rust → Dart calls.

Diplomat reduced FFI boilerplate in a recent WebRTC signaling bridge by 70%, eliminating entire classes of memory bugs.

Async FFI: Bridging Rust Futures and Dart Futures

Dart's async model and Rust's async/await don't interoperate directly. Two strategies:

1. Callback-based async: Rust spawns a task, stores a Dart callback pointer, invokes it on completion. Requires allo-isolate to safely call Dart from background threads.

2. ReceivePort polling: Rust writes results to a lock-free queue, Dart polls via a ReceivePort. Simpler but introduces 1-2ms latency.

For low-latency use cases (audio, vision), callback-based async with tokio runtime integration is necessary. A production audio pipeline using this approach achieved 3ms Rust → Dart notification latency, sufficient for 60fps video processing.

Memory Ownership Patterns

Three ownership models for FFI data:

Rust-owned: Rust allocates, Dart borrows read-only. Dart must not outlive the Rust object. Use opaque handles:

#[no_mangle]
pub extern "C" fn create_model() -> *mut Model { ... }

#[no_mangle]
pub extern "C" fn predict(model: *mut Model, input: *const f32) -> f32 { ... }

#[no_mangle]
pub extern "C" fn destroy_model(model: *mut Model) { ... }

Dart-owned: Dart allocates via calloc, Rust borrows. Useful for passing configuration structs.

Shared ownership: Use Arc on the Rust side, pass raw pointers to Dart, increment/decrement refcount via FFI functions. Complex but necessary for multi-threaded scenarios.

Benchmarking: When FFI Overhead Matters

FFI calls cost 10-30ns on modern ARM64—negligible for coarse-grained operations (processing a full video frame), catastrophic for fine-grained loops (per-pixel operations). Profile with:

// Rust: criterion.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn bench_ffi(c: &mut Criterion) {
    c.bench_function("ffi_call", |b| b.iter(|| {
        unsafe { add(black_box(2), black_box(3)) }
    }));
}

If FFI overhead exceeds 5% of total execution time, batch operations: pass arrays instead of scalars, or move more logic to Rust.

Production Checklist

  • Panic handlers: Every FFI entry point wrapped in catch_unwind.
  • Memory auditing: Valgrind or AddressSanitizer on CI to catch leaks.
  • ABI stability: Pin Rust to a specific stable release; ABI breaks between versions.
  • Platform-specific builds: Separate .so/.dylib/.dll per architecture; use flutter_rust_bridge or custom build scripts.
  • Fallback paths: Graceful degradation if Rust library fails to load (older devices, unsupported architectures).

In production apps serving millions of users, these patterns have proven robust across Android 8+, iOS 12+, and desktop platforms. The key insight: treat FFI as a compiler target boundary—explicit contracts, zero implicit conversions, and ruthless testing of edge cases.