Two worlds in one app
Your Flutter app, at any moment, contains two kinds of memory it doesn't know about each other.
The first kind is the Dart heap — the managed world you've been living in. Objects are allocated there when you write final user = User(...). The garbage collector tracks them, moves them around occasionally during compaction, and frees them when nothing holds a reference. You never think about it. The runtime handles it.
The second kind is native memory — the unmanaged world on the other side of a line most Flutter developers never cross. Memory allocated with calloc() or malloc(). No object headers, no GC metadata, no tracking. Raw bytes at a raw address. The garbage collector doesn't know it exists. If you allocate it and forget to free it, it stays allocated until the process exits — no exception, no warning, just a slow accumulation of wasted memory.
Most Flutter apps spend their entire existence in the first world. Dart is fast, the standard library is rich, and pub.dev covers an enormous range of use cases. For the majority of what mobile apps do — API calls, state management, UI rendering, navigation — you never need to leave.
But there's a boundary. And past it, there are things you genuinely cannot do in Dart — or cannot do well enough.
This series is about learning to cross that boundary deliberately, safely, and with full understanding of what changes when you step over it.
The universe of code already written in C
Let's start with the most honest reason FFI exists: the C ecosystem is enormous, old, and not being rewritten in Dart.
SQLite — the database embedded in every smartphone, every browser, and most desktop apps — is roughly 150,000 lines of C. It has been in continuous development since 2000, is tested against billions of rows of data, and has a correctness guarantee that includes a full test suite with 100% branch coverage. The Dart ecosystem has database libraries. None of them have twenty-four years of battle testing.
zlib — the compression library — is what your web browser uses when it decompresses a gzip response. It's what the ZIP in your APK is built on. It's so pervasive that you're almost certainly using it right now without knowing it.
libsodium — a cryptography library built on top of the Networking and Cryptography library (NaCl) — is the standard recommendation for anyone who needs encryption done correctly and doesn't want to design the protocol themselves. It implements Curve25519, XSalsa20, Poly1305, Ed25519. Pure Dart crypto libraries exist; for high-assurance security contexts, many teams still want the C implementation that's been audited and deployed at scale.
OpenCV — computer vision. FFmpeg — video and audio codec processing. TensorFlow Lite — on-device machine learning inference. RocksDB — an embedded key-value store used by companies at the scale of Facebook and LinkedIn.
The pattern in all of them: decades of engineering, millions of lines of optimized code, production deployments at enormous scale. You are not rewriting these in Dart. You are calling them.
And then there's the enterprise case — the one that often drives FFI adoption faster than any other. A medical device company has a signal processing library. Four years to write, eighteen months to validate against regulatory requirements. Rewriting it in Dart means revalidating it. That's not an engineering decision, it's a business one. The library works. It needs to run in Flutter.
FFI's primary purpose is not performance. It's access — to existing, proven native code that has no Dart equivalent.
The performance case, made honest
That said, the performance gap is real in specific contexts. Dart compiles to native ARM code (as covered in the Flutter Under the Hood series), and for most operations it's genuinely fast. You're not reaching for FFI because Dart is slow at parsing JSON or making HTTP requests.
But there is a category of work where C's performance advantage is decisive: tight loops over large buffers.
Consider processing a camera frame. A typical smartphone camera delivers frames at 1080p — roughly 2 million pixels. An RGBA frame is 8 million bytes. If you want to apply a filter to every pixel — adjust brightness, convert to greyscale, run a convolution — you're iterating over 8 million bytes in a tight loop, 30 or 60 times per second.
In Dart, that loop is fast but not fast enough to stay within the 16ms frame budget while also doing everything else the UI thread needs to do. A greyscale conversion on an 8MP frame takes approximately 35–50ms in pure Dart. The same operation in optimized C takes 2–4ms — because the C compiler can vectorize the loop, using SIMD instructions to process 8 or 16 pixels per CPU instruction instead of one.
The gap is real. Not because Dart is bad at arithmetic — it isn't — but because the C compiler has decades of optimization passes, knows the hardware capabilities, and can generate vectorized code that Dart's compiler currently doesn't.
For this workload, 35ms vs 2ms is not a tuning detail. It's the difference between shipping a real-time camera filter and removing the feature from the product.
Why the boundary requires understanding
Calling a C function from Dart is not like calling a Dart function from Dart. There are differences that will silently corrupt your data or silently leak memory if you don't understand them.
The garbage collector cannot see native memory
When you allocate a Dart object, the GC knows about it — it's on the managed heap, it has a header with type information and GC metadata. The GC can trace references from it, move it during compaction, and free it when it's unreachable.
When you allocate native memory with calloc(), the GC knows nothing. That memory is not on the managed heap. It has no header. The GC will never trace it, never move it, never free it. You are completely responsible.
This connects directly to Post 3's GC discussion: the GC frees objects that are unreachable on the managed heap. Native memory is never on the managed heap. The concept of "unreachable" doesn't apply to it. Forgetting to free it is not a "dangling reference" the way a forgotten StreamSubscription is — it's a true memory leak, the C kind, the kind that exists at the OS level.
Dart objects cannot be directly passed to C
A Dart String is not a C string. A Dart List<int> is not a C array. These are managed objects with GC metadata, internal structure, and potentially moving locations (the GC can relocate objects during compaction). If you somehow passed a pointer to a Dart object to a C function, and the GC ran a compacting collection while C was reading from that address, C would read garbage — or worse, valid but stale data.
FFI solves this by requiring you to explicitly convert Dart data into native memory before crossing the boundary. A Dart String becomes a Pointer<Utf8> — a raw pointer to a UTF-8 encoded byte sequence in native memory, at a fixed address that the GC won't move. A Uint8List image buffer becomes a Pointer<Uint8> — a pointer into native memory that you explicitly allocated.
The conversion is your responsibility. The lifetime of that native memory is your responsibility. And when C is done with it, the deallocation is your responsibility.
Types have different sizes and representations
A Dart int is a 64-bit signed integer on 64-bit platforms. A C int is 32 bits. A C long is 32 bits on some platforms and 64 bits on others. double is 64 bits in both, but the calling convention for passing it differs by platform and ABI.
FFI requires you to be precise. You don't say "pass an int." You say "pass an Int32" or "pass an Int64" — using FFI's explicit type aliases that map to specific C types regardless of platform. The precision is not ceremony. A mismatch doesn't produce a compile error. It produces a function that runs and returns garbage data, silently.
C++ and the naming problem
FFI speaks C. Not C++. This distinction bites every developer who first encounters it.
C++ has a feature called function overloading: two functions can have the same name if their parameter types differ. encrypt(int key, char* data) and encrypt(string key, string data) can coexist. To support this, the C++ compiler encodes type information into the compiled function name — a process called name mangling. Your function named encrypt gets compiled into a symbol like _ZN6crypto9AESCipher7encryptEPKhi. Dart's lookupFunction call takes a string name. It would need to know the mangled name to find the function.
It can't know that name without parsing C++ itself. FFI doesn't.
The solution is extern "C": a declaration that tells the C++ compiler to use plain C naming — no mangling — for specified functions. Every C++ function you want to call from Dart must be wrapped in an extern "C" block:
// Your C++ implementation — use C++ freely inside
class SignalProcessor {
public:
float* process(const float* input, int32_t length);
};
// Your C API — what Dart can see and call
extern "C" {
float* signal_process(const float* input, int32_t length) {
SignalProcessor processor;
return processor.process(input, length);
}
void signal_free(float* buffer) {
delete[] buffer;
}
}The extern "C" block is the contract at the boundary. Inside it: plain C naming, callable from Dart. Outside it: C++ internals that Dart never touches. This pattern — a thin C wrapper around a C++ implementation — is how every major C++ library exposes itself to FFI callers, in any language.
The type system: why every call needs two signatures
When you look up a native function with dart:ffi, you always provide two type parameters:
final myFunction = lib.lookupFunction<
NativeSignature, // ← describes the C side
DartSignature // ← describes what Dart works with
>('function_name');This isn't ceremony. It's a consequence of the two-world model.
The native signature uses FFI types: Int32, Int64, Float, Double, Bool, Void, Pointer<T>. These types don't correspond to Dart objects — they're compile-time descriptions of how many bytes the C function expects at each position in the call, and how to interpret them. Int32 means "a 4-byte signed integer in two's complement representation, passed in a CPU register or stack slot according to the platform's calling convention."
The Dart signature uses normal Dart types: int, double, bool, void. These are what your code actually works with. The FFI machinery handles the conversion between them — marshalling a Dart int into the native Int32 representation before the call, and converting the Int32 return value back to a Dart int after.
The full type vocabulary for primitive values:
FFI Type C Type Dart Type Size
──────────────────────────────────────────────
Int8 int8_t int 1 byte
Int16 int16_t int 2 bytes
Int32 int32_t int 4 bytes
Int64 int64_t int 8 bytes
Uint8 uint8_t int 1 byte
Uint16 uint16_t int 2 bytes
Uint32 uint32_t int 4 bytes
Uint64 uint64_t int 8 bytes
Float float double 4 bytes
Double double double 8 bytes
Bool bool bool 1 byte
Void void void —
Pointer<T> T* Pointer<T> platform sizePointer<T> is the type that unlocks everything complex — strings, arrays, structs, callbacks. It's also where the memory management responsibility begins. We'll spend most of Post 2 here.
Loading a native library
Before you can call a function, you need to load the library it lives in. This is where Android and iOS diverge.
On Android, your compiled C/C++ code lives as a shared library — libmylibrary.so — inside the APK's lib/arm64-v8a/ directory (alongside libflutter.so and libapp.so, as we saw in the article about Flutter compilation process). At runtime, you load it by name:
import 'dart:ffi';
import 'dart:io';
final DynamicLibrary _lib = DynamicLibrary.open('libmylibrary.so');This call to dlopen() (under the hood) finds the .so in the app's library path, loads it into the process's address space, and returns a handle. From that handle, you look up function symbols by name.
On iOS, the story is different. Apple's App Store guidelines restrict the dynamic loading of code at runtime — for security reasons, you can't download and execute native code after an app is installed. Everything must be compiled in at build time. Flutter's C/C++ code on iOS is therefore statically linked directly into the app binary. There's no .so to open.
Instead:
final DynamicLibrary _lib = DynamicLibrary.process();DynamicLibrary.process() returns a handle to the current process itself — which, because everything was statically linked, contains all your native symbols. The lookup API is the same; only the loading mechanism differs.
In practice, you'll write this once:
final DynamicLibrary _nativeLib = Platform.isAndroid
? DynamicLibrary.open('libmylibrary.so')
: DynamicLibrary.process();And it works on both platforms.
A complete first example, from C to Flutter
Let's build the full pipeline — C source, build configuration, Dart bindings, Flutter call — for a function simple enough to understand completely but real enough to show every component.
The C code
// native/src/native_math.c
#include <stdint.h>
#include <math.h>
// Is a given number prime? Returns 1 if prime, 0 if not.
int32_t is_prime(int64_t n) {
if (n < 2) return 0;
if (n == 2) return 1;
if (n % 2 == 0) return 0;
for (int64_t i = 3; i * i <= n; i += 2) {
if (n % i == 0) return 0;
}
return 1;
}
// Count primes up to n (inclusive)
int64_t count_primes(int64_t n) {
int64_t count = 0;
for (int64_t i = 2; i <= n; i++) {
if (is_prime(i)) count++;
}
return count;
}Nothing exotic — trial division primality testing. But counting primes up to 10,000,000 in a tight loop takes meaningful time. In Dart: roughly 800ms. In this C code: roughly 80ms. In optimized C with a Sieve of Eratosthenes: under 10ms. The gap is real for this workload.
Build configuration — Android
Create android/app/src/main/cpp/CMakeLists.txt:
cmake_minimum_required(VERSION 3.18.1)
project("native_math")
add_library(
native_math # The library name — produces libnative_math.so
SHARED # Shared library (.so) — loaded at runtime
../../../../native/src/native_math.c
)
# math.h functions like sqrt() need libm
target_link_libraries(native_math m)Tell Gradle to use it — in android/app/build.gradle:
android {
defaultConfig {
externalNativeBuild {
cmake { cppFlags "" }
}
}
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.18.1"
}
}
}Build configuration — iOS
In ios/Runner.xcodeproj, add native_math.c to the Runner target's "Compile Sources" build phase. No CMakeLists.txt needed — Xcode's Clang compiler handles it directly. The function is statically linked into the app binary.
If you're building a reusable Flutter plugin rather than embedding native code in an app, use a podspec instead, which is more portable.
The Dart bindings
// lib/src/native_math.dart
import 'dart:ffi';
import 'dart:io';
// Native signatures — describe the C function's ABI
typedef _IsPrimeNative = Int32 Function(Int64 n);
typedef _CountPrimesNative = Int64 Function(Int64 n);
// Dart signatures — what we work with in Dart code
typedef _IsPrimeDart = int Function(int n);
typedef _CountPrimesDart = int Function(int n);
class NativeMath {
static final DynamicLibrary _lib = Platform.isAndroid
? DynamicLibrary.open('libnative_math.so')
: DynamicLibrary.process();
static final _IsPrimeDart _isPrime =
_lib.lookupFunction<_IsPrimeNative, _IsPrimeDart>('is_prime');
static final _CountPrimesDart _countPrimes =
_lib.lookupFunction<_CountPrimesNative, _CountPrimesDart>('count_primes');
/// Returns true if [n] is a prime number.
static bool isPrime(int n) => _isPrime(n) == 1;
/// Returns the count of prime numbers from 2 to [n] inclusive.
static int countPrimes(int n) => _countPrimes(n);
}The lookupFunction calls happen once at class initialization — not on every call. The returned function objects (_isPrime, _countPrimes) are cached and reused. Function lookup involves a symbol table search; doing it once per call would be wasteful.
Calling it from Flutter
// In a widget or use case
Future<void> _benchmarkPrimes() async {
final stopwatch = Stopwatch()..start();
// Run on a separate isolate — this is a CPU-intensive operation
final count = await Isolate.run(() => NativeMath.countPrimes(10000000));
stopwatch.stop();
print('Found $count primes in ${stopwatch.elapsedMilliseconds}ms');
}The Isolate.run() wrapper matters. Even though the C function is fast, "fast" is relative. Counting primes up to 10 million takes 80ms. Eighty milliseconds on the UI thread means five dropped frames. Every FFI call that takes more than a millisecond or two belongs on a background isolate — the same principle as keeping the event loop clear in Node.js (as we discussed in the JS series), applied here to Flutter's rendering budget.
The memory rule, stated once and meant forever
Before the next post goes deep into Pointer<T>, strings, and structs, this rule needs to be internalized:
Every byte you allocate in native memory, you free. The GC will not.
In practice this means the try/finally pattern is non-negotiable:
import 'package:ffi/ffi.dart'; // provides calloc
void exampleWithNativeMemory() {
// Allocate 1024 bytes of native memory
final buffer = calloc<Uint8>(1024);
try {
// Use the buffer — pass to C, read results, whatever
someNativeFunction(buffer, 1024);
} finally {
// Always runs, even if an exception is thrown
calloc.free(buffer);
}
}If someNativeFunction throws a Dart exception and calloc.free is outside the finally, that 1024 bytes leaks. In a function called once, irrelevant. In a function called for every camera frame at 60fps, it's 61,440 bytes per second of leaked memory — 3.6MB per minute, until the OS kills the app.
There's no GC to catch this. There's no Flutter framework warning. There's no exception. Just a process that slowly consumes more and more memory until the OS decides it's done.
The discipline is absolute: allocate native memory, put the free in a finally, never leave the boundary without it.
ffigen: when you don't write bindings by hand
For a small C library with two or three functions, writing the bindings by hand is fine. For a library like SQLite — with over a hundred public functions — it's not.
ffigen is a Dart tool that reads C header files and generates the bindings automatically. You add it to dev_dependencies, point it at your .h files, run one command, and it produces a complete Dart file with all the typedefs, all the lookupFunction calls, and all the struct definitions.
We'll cover ffigen properly when we integrate a real library in Post 5. For now: it exists, it works well, and you should know about it before you start writing bindings for a 200-function API by hand.
FFI vs platform channels — the clean distinction
There are two ways Flutter talks to native code. Understanding the difference prevents reaching for the wrong one.
Platform channels are for calling platform APIs — the operating system's capabilities. Reading contacts, accessing the camera, using Bluetooth, sending a push notification. The platform (iOS/Android) has an implementation; you're calling it. The channel passes a message, the platform handles it, the result comes back.
FFI is for calling code you bring yourself — a C library, your own C++ code. The platform is not involved. You compiled the code, you linked it, you call it directly.
Platform channels have overhead — message serialization, thread hops, platform thread dispatch. For occasional API calls this is imperceptible. For tight loops over camera frames, it's prohibitive. FFI has essentially no overhead beyond the function call itself — it's a direct CPU instruction.
The rule: if a pub.dev package exists for what you need, use it. Most of them are already wrapping C libraries via FFI or platform channels internally. If you have C/C++ code to run yourself, FFI. If you need OS capabilities no package covers, platform channels.
What this series builds
Post 1 has given you the why and the skeleton — the conceptual model, the type system, the build configuration, the first working call. From here, the series goes deeper:
Post 2 — Pointers, strings, and the hard part: passing complex types across the boundary. Pointer<Uint8>, calloc, converting Dart String to char* and back, the danger of mixing GC memory and native memory.
Post 3 — Structs: mapping C data structures to Dart. Field alignment, padding, nested structs, the category of bugs that produces silent data corruption rather than clear errors.
Post 4 — Callbacks: C calling Dart. NativeCallable — what it is, why it's harder than Dart calling C, and the thread safety implications when C calls back from a thread the Dart runtime doesn't own.
Post 5 — A real integration: calling libsodium from Flutter for authenticated encryption. The full pipeline: download the library, write a CMakeLists.txt, use ffigen to generate bindings, wrap it in a clean Dart API, test it.
The goal by the end of this series: you'll be able to take any C library, integrate it into a Flutter project on both Android and iOS, wrap it safely, and use it without leaking memory or corrupting data.
That's a capability most Flutter developers don't have. It's also the capability that lets you do things with Flutter that most Flutter developers think aren't possible.