How Flutter-Rust FFI Communication Works in AppFlowy: A Deep Dive into the Bridge Architecture

AppFlowy implements Flutter-Rust FFI communication through a Foreign Function Interface layer where Dart's DynamicLibrary loads compiled Rust shared objects, enabling zero-copy protobuf serialization across the boundary with async responses posted back to Dart isolates via the allo_isolate crate.

The AppFlowy-IO/AppFlowy repository embeds performance-critical business logic—such as document storage, database operations, and real-time collaboration—inside a Rust core library. This architecture requires a robust, low-latency bridge to the Flutter UI layer, implemented through a thin FFI abstraction that maintains thread safety and platform independence.

Architecture of the FFI Communication Layer

The Three-Tier Pipeline

The communication stack operates across three distinct layers. On the Flutter side, Dart's DynamicLibrary loads the platform-specific shared object (libdart_ffi.so, .dylib, or .dll). This library exposes C-ABI functions marked with #[no_mangle] extern "C" in the Rust source, such as init_sdk, async_event, and set_stream_port. These functions serve as the entry points to the AppFlowy Core, where AFPluginRuntime and AppFlowyCore instances handle plugin events, data persistence, and networking on a dedicated Tokio runtime.

Key Components for Cross-Platform Messaging

  • FFIRequest / FFIResponse: Protobuf-generated types that convert between raw byte buffers used by the FFI boundary and internal Rust types (AFPluginRequest and AFPluginEventResponse).
  • allo_isolate: A Rust crate that enables posting messages back to specific Dart isolates using numeric port identifiers, creating an async channel across the language boundary.
  • Port-Based Channels: The UI creates an Isolate and registers its port via set_stream_port. Rust stores this in a thread-safe RwLock<Option<Isolate>> (specifically LOG_STREAM_ISOLATE), allowing background threads to post responses without blocking the Flutter UI.

Step-by-Step FFI Communication Flow

1. Loading the Native Library

The Dart side initializes the bridge by loading the compiled Rust library into memory.

final DynamicLibrary _dart_ffi_lib = _open();   // loads libdart_ffi.so / .dylib / .dll

2. Initializing the SDK

Flutter calls init_sdk to bootstrap the Rust environment, passing a JSON configuration string that specifies application version, device ID, and platform type.

int init_sdk(int port, Pointer<Utf8> data) => _init_sdk(port, data);

On the Rust side (in frontend/rust-lib/dart-ffi/src/lib.rs), this function parses the JSON, initializes environment variables, creates an AppFlowyCore instance, and stores the runtime handle for subsequent requests.

3. Establishing the Async Response Channel

Before sending commands, the UI registers a port for receiving async responses. The set_stream_port function stores the Dart isolate in Rust's LOG_STREAM_ISOLATE, a global RwLock that enables thread-safe access from background tasks.

int set_stream_port(int port) => _set_stream_port(port);

4. Dispatching Async Events

When the UI needs to perform operations like creating a document, it constructs an FFIRequest protobuf message, serializes it to bytes, and passes a raw pointer to async_event.

void async_event(int port, Pointer<Uint8> input, int len) => _invoke_async(port, input, len);

The Rust implementation of async_event (in lib.rs) converts the raw buffer into an AFPluginRequest and dispatches it through DART_APPFLOWY_CORE.dispatch, which routes the command to the appropriate plugin on a Tokio background thread.

5. Processing and Responding

The AFPluginDispatcher::boxed_async_send_with_callback executes the requested operation (e.g., database queries or network synchronization). Upon completion, the system invokes post_to_flutter, which converts the AFPluginEventResponse into an FFIResponse, serializes it to bytes, and posts it to the Dart isolate.

async fn post_to_flutter(response: AFPluginEventResponse, port: i64) {
    let ffi_resp = FFIResponse::from(response);
    let bytes = ffi_resp.into_bytes().unwrap().to_vec();
    let isolate = allo_isolate::Isolate::new(port);
    isolate.catch_unwind(async { isolate.post(bytes) }).await;
}

6. Receiving Data in Flutter

The Dart isolate listens for incoming byte buffers, deserializes them using the generated FFIResponse class, and forwards the payload to the UI layer for rendering.

Implementation Examples

Initializing the Native SDK in Dart

The following pattern from frontend/appflowy_flutter/packages/appflowy_backend/lib/ffi.dart demonstrates proper initialization and memory management:

Future<void> initAppFlowy() async {
  final config = {
    'app_version': '0.6.0',
    'device_id': 'flutter-device',
    'platform': Platform.isIOS ? 'ios' : Platform.isAndroid ? 'android' : 'desktop',
  };
  final jsonStr = jsonEncode(config);
  final ptr = jsonStr.toNativeUtf8();
  const port = 5000;
  
  init_sdk(port, ptr);
  set_stream_port(port);
  calloc.free(ptr);
}

Sending Async Events from Flutter

This snippet shows how to build a protobuf request and transmit it across the FFI boundary:

Future<void> createDocument(String docId) async {
  final request = FFIRequest()
    ..event = 'create_document'
    ..payload = jsonEncode({'doc_id': docId}).codeUnits;

  final bytes = request.writeToBuffer();
  final ptr = malloc.allocate<Uint8>(bytes.length);
  final native = ptr.asTypedList(bytes.length);
  native.setAll(0, bytes);
  
  async_event(5000, ptr, bytes.length);
  malloc.free(ptr);
}

Receiving Responses via Isolates

Dart registers a callback to handle bytes posted from Rust:

void startResponseListener(int port) {
  IsolateNameServer.registerPortWithName(port, (Uint8List data) {
    final response = FFIResponse.fromBuffer(data);
    final payload = utf8.decode(response.payload);
    print('Received response: $payload');
  });
}

Handling Requests in Rust

The entry point in frontend/rust-lib/dart-ffi/src/lib.rs demonstrates zero-copy conversion:

#[no_mangle]
pub extern "C" fn async_event(port: i64, input: *const u8, len: usize) {
    let request: AFPluginRequest = FFIRequest::from_u8_pointer(input, len).into();
    DART_APPFLOWY_CORE.dispatch(request, port, None);
}

Key Source Files

Understanding the following files is essential for working with the AppFlowy FFI layer:

Summary

  • Zero-copy serialization: AppFlowy uses raw byte buffers and protobuf messages (FFIRequest/FFIResponse) to minimize data copying overhead across the language boundary.
  • Isolate-based async communication: The allo_isolate crate enables Rust background threads to safely post responses to specific Dart isolates using numeric ports, preventing UI blocking.
  • Thread-safe architecture: All heavy processing (database operations, networking, indexing) runs on a dedicated Tokio runtime in Rust, while the Flutter thread remains responsive.
  • Platform abstraction: The same Dart code targets Android, iOS, macOS, Linux, and Windows by loading the appropriate shared object format through DynamicLibrary.

Frequently Asked Questions

How does AppFlowy handle async responses from Rust to Flutter?

AppFlowy uses a port-based channel system. During initialization, Dart calls set_stream_port to register an isolate identifier with Rust, which stores it in a thread-safe RwLock. When an async operation completes, Rust uses allo_isolate::Isolate::new(port).post(bytes) to deliver the serialized response back to the specific Dart isolate listening on that port.

What serialization format does AppFlowy use for FFI messages?

The system uses Protocol Buffers (protobuf). Dart constructs FFIRequest messages and serializes them to Uint8List buffers before passing pointers to Rust. Conversely, Rust constructs FFIResponse objects from internal AFPluginEventResponse types and serializes them to bytes for Dart deserialization.

Why does AppFlowy use a dedicated Tokio runtime for the Rust core?

The dedicated Tokio runtime ensures that all blocking operations—such as SQLite queries, file I/O, and HTTP requests—execute on background threads. This design keeps the Flutter UI thread unblocked, providing a responsive user experience while leveraging Rust's performance for compute-intensive tasks.

How is the native library loaded across different platforms?

The Dart DynamicLibrary automatically selects the correct binary format based on the operating system: .so for Linux/Android, .dylib for macOS/iOS, and .dll for Windows. The ffi.dart file contains platform detection logic that loads libdart_ffi with the appropriate extension, enabling a single Dart codebase to target all supported platforms.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →