← Back to blog
Dart Internals & Performance · Part 5 of 8
September 9, 202613 min read

Dart FFI: Calling Native C Code from Dart

DartPerformanceFlutter

Dart FFI: Calling Native C Code from Dart

This is Part 5 of the Dart Internals & Performance series. So far we've stayed inside the managed Dart world — the VM, the GC, compile-time constants. Now we punch a hole through the wall and call native machine code directly: existing C libraries, OS APIs, hand-tuned numeric kernels.

This is FFI — Foreign Function Interface — exposed through Dart's built-in dart:ffi. It's how you use SQLite, a crypto library, a game engine, or a C image codec from Dart without a platform-channel round trip. It's also where Dart's friendly guarantees stop: cross the FFI boundary and you're responsible for memory, types, and lifetimes the way a C programmer is.

Assumes the runtime/GC model from Part 1 and Part 3. We're on Dart 3.12.


Why FFI exists

Analogy — two countries, one border. Dart is a country with its own currency (managed objects), its own police (the GC), and its own language (the type system). C is the neighbouring country with different money (raw pointers), no police (you free your own memory), and a different language (machine-level types). FFI is the border crossing: a checkpoint where you exchange currency and translate documents so the two sides can do business.

You reach for FFI when:

  • A mature C/C++ library already solves your problem (SQLite, libsodium, OpenCV, FFmpeg, a vendor SDK).
  • You need raw native performance for a tight numeric loop (though often an isolate in pure Dart is enough — measure first, Part 6).
  • You must call a platform/OS API with no Dart wrapper.

FFI vs platform channels: platform channels (MethodChannel) are asynchronous message passing to Kotlin/Swift — great for platform plugins, but they serialize arguments and hop threads. FFI is a direct synchronous function call into native code in the same process — lower overhead, but you handle C-level details yourself.


The four steps of every FFI call

Every FFI integration is the same shape:

  1. Load the native library.
  2. Look up the symbol and describe its signature twice — once in C terms, once in Dart terms.
  3. Marshal arguments across the border (convert Dart values ↔ native memory).
  4. Manage memory you allocated, because the GC won't.

Let's build it up from the simplest possible call.

Step 1: Load the library

import 'dart:ffi';
import 'dart:io' show Platform;

final DynamicLibrary nativeLib = switch (Platform.operatingSystem) {
  'android' => DynamicLibrary.open('libnative.so'),
  'ios'     => DynamicLibrary.process(),       // statically linked into the app
  'windows' => DynamicLibrary.open('native.dll'),
  'macos'   => DynamicLibrary.open('libnative.dylib'),
  _         => DynamicLibrary.open('libnative.so'),
};
  • DynamicLibrary.open(path) loads a shared library by file.
  • DynamicLibrary.process() / .executable() find symbols already linked into the running process (common on iOS, where dynamic libs are restricted).

Step 2: Look up a function (the two-signature dance)

Say C exposes int32_t add(int32_t a, int32_t b);. To call it you describe it twice: the native signature using dart:ffi types, and the Dart signature using plain Dart types.

// Native C signature, expressed with ffi types:
typedef NativeAdd = Int32 Function(Int32 a, Int32 b);
// Dart-facing signature (what YOU call):
typedef DartAdd = int Function(int a, int b);

final add = nativeLib.lookupFunction<NativeAdd, DartAdd>('add');

void main() => print(add(3, 4)); // 7 — a real native call

Why two signatures? The native one (Int32, Float, Pointer) tells the FFI exactly how bits are laid out for the C ABI. The Dart one (int, double) is the friendly API you actually use. lookupFunction glues them together and generates the marshalling.


Mapping types across the border

Here's the translation table you'll keep coming back to. Native types live in dart:ffi; the Dart column is what you write in code.

| C type | dart:ffi native type | Dart type | | --- | --- | --- | | int8_t / int16_t / int32_t / int64_t | Int8 / Int16 / Int32 / Int64 | int | | uint8_tuint64_t | Uint8Uint64 | int | | float | Float | double | | double | Double | double | | bool | Bool | bool | | void | Void | void | | T* (pointer) | Pointer<T> | Pointer<T> | | char* (C string) | Pointer<Char> / Pointer<Utf8> | (convert — see below) | | size_t / intptr_t | Size / IntPtr | int | | a struct | extends Struct | your struct class |

Gotcha: native integer types map to Dart int, and native Float/Double both map to Dart double. The native type controls the bit width on the wire; the Dart side is always the 64-bit int/double. Pick the native type to match C exactly, or you'll read garbage.


Pointers, memory, and package:ffi

The moment you deal with strings, arrays, or output parameters, you need native memory — memory the Dart GC neither allocates nor frees. The community package:ffi adds the allocators and string helpers that make this bearable.

import 'dart:ffi';
import 'package:ffi/ffi.dart'; // malloc, calloc, Utf8

void main() {
  // Allocate native memory for 4 int32s. This is OUTSIDE the Dart heap.
  final Pointer<Int32> buffer = calloc<Int32>(4);

  buffer[0] = 10;             // write via index
  buffer[1] = 20;
  print(buffer[0] + buffer[1]); // 30

  calloc.free(buffer);        // ❗ YOU must free it — the GC never will
}

The cardinal rule of FFI memory: native allocations (malloc/calloc) are not tracked by the garbage collector (Part 3). Every malloc/calloc needs a matching free, or you leak — exactly like C. The GC manages Dart objects; it has no idea your Pointer exists.

Strings cross the border by hand

C strings are null-terminated char*; Dart strings are managed objects. You convert explicitly:

import 'package:ffi/ffi.dart';

void main() {
  // Dart String → native UTF-8 (allocates native memory):
  final Pointer<Utf8> cName = 'Ada'.toNativeUtf8();

  // ... pass cName to a C function expecting char* ...

  // Native UTF-8 → Dart String:
  final String back = cName.toDartString();
  print(back); // Ada

  calloc.free(cName); // free the native string when done
}

Structs: laying out C records in Dart

To pass or receive a C struct, you declare a Dart class that extends Struct, annotating each field with its native type. The fields are external (their storage is the native memory, not a Dart object).

// C:
typedef struct { double x; double y; } Coord;
double distance(Coord* a, Coord* b);
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'dart:math' as math;

final class Coord extends Struct {
  @Double()
  external double x;

  @Double()
  external double y;
}

typedef NativeDistance = Double Function(Pointer<Coord>, Pointer<Coord>);
typedef DartDistance = double Function(Pointer<Coord>, Pointer<Coord>);

void main() {
  final a = calloc<Coord>();
  final b = calloc<Coord>();
  a.ref.x = 0; a.ref.y = 0;     // '.ref' dereferences the pointer to the struct
  b.ref.x = 3; b.ref.y = 4;

  final distance = nativeLib
      .lookupFunction<NativeDistance, DartDistance>('distance');
  print(distance(a, b)); // 5.0

  calloc.free(a);
  calloc.free(b);
}

.ref is the dereference. pointer.ref gives you the struct instance backed by that native memory, so pointer.ref.x = 3 writes directly into the C layout. Fixed-size inline arrays use @Array(n) with an Array<T> field; unions use extends Union.


Callbacks: letting C call back into Dart

Sometimes C needs a function pointer to call your Dart code (e.g. a comparator, an event handler). Dart 3.x gives you NativeCallable:

import 'dart:ffi';

// A Dart function C can invoke:
int onEvent(int code) {
  print('native told us: $code');
  return code * 2;
}

void main() {
  // isolateLocal: synchronous, must run on the SAME isolate/thread as the call.
  final callback = NativeCallable<Int32 Function(Int32)>.isolateLocal(onEvent);

  final Pointer<NativeFunction<Int32 Function(Int32)>> fnPointer =
      callback.nativeFunction;

  // ... hand fnPointer to a C function that stores/invokes it ...

  callback.close(); // release the callback when the native side is done with it
}

Two flavours matter:

  • NativeCallable.isolateLocal — synchronous; native code calls your Dart function and gets a return value, but it must be invoked on the isolate that created it.
  • NativeCallable.listener — for native code on another thread that fires events asynchronously; it posts the call to your isolate's event queue (no return value). This is the safe way to receive callbacks from native threads.

The older Pointer.fromFunction still exists for top-level/static functions, but NativeCallable is the modern, more flexible API (and the only safe option for cross-thread callbacks). Prefer it.


Tying native lifetime to Dart objects: NativeFinalizer

Manual free is error-prone. To tie a native resource's cleanup to a Dart object's lifetime, use a NativeFinalizer — a bridge back to Part 3's Finalizer idea, but for native memory:

import 'dart:ffi';
import 'package:ffi/ffi.dart';

// Wraps a native buffer; when the Dart wrapper is collected,
// the finalizer runs `free` on the native pointer.
class NativeBuffer implements Finalizable {
  static final _finalizer = NativeFinalizer(calloc.nativeFree.cast());
  final Pointer<Uint8> ptr;

  NativeBuffer(int size) : ptr = calloc<Uint8>(size) {
    _finalizer.attach(this, ptr.cast(), detach: this);
  }

  void dispose() {
    _finalizer.detach(this);
    calloc.free(ptr); // deterministic cleanup preferred
  }
}

Still prefer explicit dispose(). A finalizer is a safety net for leaks if you forget — its timing is non-deterministic (it runs whenever the GC gets around to it). For anything scarce, free deterministically and use the finalizer only as backup.


The performance footgun: FFI calls block the thread

An FFI call is a synchronous native call on the current isolate's thread. If the native function runs for 200 ms, your isolate is blocked for 200 ms — and if that's the UI isolate, you've janked the frame (Part 2's "never block the thread," now at the native level).

// ❌ A long native call on the UI isolate freezes the app:
final result = heavyNativeComputation(bigInput);

// ✅ Run it in a background isolate so the UI thread stays free:
final result = await Isolate.run(() => heavyNativeComputation(bigInput));

Rule: treat a slow FFI call exactly like slow CPU work — push it to a background isolate (Part 3). Note that pointers can't be sent between isolates trivially (each isolate has its own heap); typically you do the whole native interaction inside the worker isolate.


You usually don't hand-write bindings: ffigen

For a real C library with hundreds of functions and structs, writing the two-signature dance by hand is madness. The ffigen tool parses C headers and generates all the Dart bindings for you.

# ffigen config (pubspec or ffigen.yaml)
ffigen:
  name: NativeLibrary
  output: 'lib/native_bindings.dart'
  headers:
    entry-points:
      - 'src/native.h'
dart run ffigen

Workflow in practice: write/obtain the C library, point ffigen at its headers, get type-safe Dart bindings generated, then call them. Pair it with native assets / the @Native external-function style (still stabilizing) so the build system bundles and links the library for you. This connects to code generation in Part 7.


Practice Challenges

Challenge 1 — Two signatures. C declares double scale(double v, int factor);. Write the two typedefs and the lookupFunction call.

Show solution
typedef NativeScale = Double Function(Double, Int32);
typedef DartScale = double Function(double, int);

final scale = lib.lookupFunction<NativeScale, DartScale>('scale');

Native Double/Int32 describe the C ABI; the Dart side uses double/int.

Challenge 2 — Find the leak. What's wrong here?

void greet(String name) {
  final c = name.toNativeUtf8();
  nativeGreet(c);
}
Show solution

toNativeUtf8() allocates native memory that the GC never reclaims, and it's never freed → a leak on every call. Fix:

void greet(String name) {
  final c = name.toNativeUtf8();
  try {
    nativeGreet(c);
  } finally {
    calloc.free(c);
  }
}

Challenge 3 — .ref. Given final p = calloc<Coord>();, how do you set its x field to 1.5, and what does .ref do?

Show solution

p.ref.x = 1.5;. p is a Pointer<Coord>; .ref dereferences it to the Coord struct instance backed by that native memory, so assigning .x writes directly into the C struct layout.

Challenge 4 — Why the jank? A dev wraps a 300 ms native image filter in FFI and calls it in an onTap. The app freezes. Explain and fix.

Show solution

An FFI call is synchronous and runs on the calling isolate's thread — the UI isolate here. For 300 ms the event loop can't paint frames, so the app freezes. Fix: run it off the UI isolate, e.g. await Isolate.run(() => nativeFilter(bytes)), keeping the native work on a background thread.

Challenge 5 — Callback choice. A native networking library calls your callback from its own background thread when data arrives. Which NativeCallable constructor do you use and why?

Show solution

NativeCallable.listener. The native call originates on a different thread, so you can't run Dart synchronously there; listener safely posts the invocation to your isolate's event queue (asynchronous, no return value). isolateLocal would be wrong — it requires being called on the owning isolate's thread.


Questions to test yourself

Q1 (basic). What is FFI and what does dart:ffi let you do?

Show answer

FFI (Foreign Function Interface) lets Dart call native machine code directly. dart:ffi provides the types and mechanisms to load native libraries, describe C function signatures, marshal arguments, and work with native pointers/structs — a direct synchronous call into C, no platform-channel hop.

Q2 (basic). Why does an FFI function need two signatures?

Show answer

The native signature (Int32, Double, Pointer<T>) describes the exact C ABI / bit layout; the Dart signature (int, double) is the friendly API you call. lookupFunction uses both to generate the marshalling between them.

Q3 (intermediate). Who is responsible for freeing memory you allocate with malloc/calloc, and why?

Show answer

You are. Native allocations live outside the Dart heap, so the garbage collector doesn't track or free them (Part 3). Every malloc/calloc needs a matching free, or you leak — just like C.

Q4 (intermediate). How do you pass a Dart String to a C function expecting char*, and what's the catch?

Show answer

Convert with someString.toNativeUtf8() (from package:ffi) to get a Pointer<Utf8>, pass it, and convert back with .toDartString() if needed. The catch: toNativeUtf8() allocates native memory you must free afterward, or you leak.

Q5 (advanced). Why can a single FFI call jank a Flutter UI, and what's the remedy?

Show answer

FFI calls are synchronous and run on the calling isolate's thread. A long native call blocks that thread's event loop, so if it's the UI isolate, frames can't paint and the app freezes. The remedy is to run the call on a background isolate (e.g. Isolate.run), keeping the native work off the UI thread.

Q6 (advanced). Compare NativeCallable.isolateLocal and NativeCallable.listener, and say when each is required.

Show answer

isolateLocal is synchronous and must be invoked on the isolate that created it — use it when native code calls back on the same thread and you need a return value. listener is asynchronous: it posts the invocation to the owning isolate's event queue, so it's required when native code fires the callback from another thread (no return value). Cross-thread callbacks must use listener.


Wrapping up

  • FFI (dart:ffi) is a direct, synchronous call into native C code — lower overhead than platform channels, but you take on C-level responsibility.
  • Every call: load the library, describe it with two signatures (native ffi types + Dart types), marshal arguments, and manage memory.
  • Native types (Int32, Double, Pointer<T>, extends Struct) describe the C ABI; .ref dereferences a struct pointer.
  • Native memory from malloc/calloc is invisible to the GCfree it yourself, or use a NativeFinalizer as a safety net (deterministic dispose() preferred).
  • Receive native callbacks with NativeCallable (listener for cross-thread). FFI calls block the isolate's thread — push slow ones to a background isolate.
  • For real libraries, generate bindings with ffigen instead of hand-writing them.

In Part 6 we zoom back out to the whole app and get systematic about speed: writing efficient Dart — profiling with DevTools, reading a flame chart, and the concrete anti-patterns that quietly cost you frames.