0Pricing
Flutter Mobile Development · Lesson

Background Isolates and Native Memory Management

Run FFI calls off the main isolate and manage native allocations to avoid leaks.

Background Isolates and Native Memory Management is a free Flutter Mobile Development lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Flutter Mobile Development learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Why FFI Belongs Off the Main Isolate

Dart's FFI lets you call native C functions directly, but those calls run synchronously on whatever isolate invokes them. If a native function is slow (image decoding, crypto, parsing a large buffer), running it on the main isolate blocks the UI thread and drops frames.

  • The Flutter UI runs on the root (main) isolate; jank appears when it stalls more than ~16ms.
  • Native code has no awareness of Dart's event loop. A 200ms C call freezes the app.
  • The fix: run heavy FFI work on a background isolate so the main isolate stays free to paint.

This lesson covers spawning isolates, sharing native pointers safely, and freeing native memory without leaks.

Isolates vs Threads: The Memory Model

A Dart isolate is an independent worker with its own memory heap and event loop. Unlike OS threads, isolates do not share Dart objects — they communicate by passing messages over ports.

  • Each isolate has its own GC heap; you cannot pass a normal Dart object by reference between them.
  • But native memory is different: a Pointer<T> from dart:ffi is just an integer address into the process's shared native heap.
  • That address is valid in any isolate of the same process — so FFI pointers can be shared across isolates, unlike Dart objects.

This distinction is the key to coordinating FFI work between isolates: you pass the address, not the object.

The Easy Win: Isolate.run

For a one-shot heavy computation, Isolate.run (Dart 2.19+) spawns a short-lived isolate, runs your closure, returns the result, and tears the isolate down automatically. It is the simplest way to move work off the main isolate.

  • The closure must be a top-level or static function reference, or a closure that only captures sendable values.
  • The return value is copied back to the calling isolate.
  • Perfect for a single FFI-heavy operation like hashing a file.
import 'dart:isolate';

int slowFib(int n) {
  if (n < 2) return n;
  return slowFib(n - 1) + slowFib(n - 2);
}

Future<void> main() async {
  // Runs slowFib on a fresh background isolate; UI isolate stays free.
  final result = await Isolate.run(() => slowFib(38));
  print('fib(38) = ' + result.toString());
}

Allocating Native Memory with malloc

The package:ffi library exposes malloc (and calloc) allocators that wrap the C heap. You allocate a typed pointer, write to it, hand it to native code, and you are responsible for freeing it.

  • malloc<Uint8>(count) returns a Pointer<Uint8> to count bytes.
  • The Dart GC does not manage this memory — forgetting malloc.free leaks it permanently.
  • Wrap usage in try/finally so the free runs even if native code throws.
import 'dart:ffi';
import 'package:ffi/ffi.dart';

void main() {
  final buffer = malloc<Uint8>(4);
  try {
    final bytes = buffer.asTypedList(4);
    bytes.setAll(0, [10, 20, 30, 40]);
    print('first byte = ' + buffer.value.toString());
    print('sum = ' + bytes.reduce((a, b) => a + b).toString());
  } finally {
    malloc.free(buffer); // never skip this
  }
}

Passing a Pointer Across an Isolate Boundary

Because a native pointer is an integer address, you can send its address field to a background isolate, then reconstruct the typed pointer there with Pointer.fromAddress.

  • Send ptr.address (a plain int) — it is sendable over a SendPort.
  • On the receiving isolate, rebuild it: Pointer<Uint8>.fromAddress(addr).
  • Ownership rule: decide which isolate frees the memory. A common pattern is the owner allocates, the worker only reads/writes, and the owner frees after the worker signals completion.
import 'dart:ffi';
import 'dart:isolate';
import 'package:ffi/ffi.dart';

int sumOnWorker(int address, int length) {
  final ptr = Pointer<Uint8>.fromAddress(address);
  final list = ptr.asTypedList(length);
  return list.fold<int>(0, (a, b) => a + b);
}

Future<void> main() async {
  final buf = malloc<Uint8>(5);
  try {
    buf.asTypedList(5).setAll(0, [1, 2, 3, 4, 5]);
    // Owner (main) keeps ownership; worker only reads via the address.
    final total = await Isolate.run(() => sumOnWorker(buf.address, 5));
    print('sum from worker = ' + total.toString());
  } finally {
    malloc.free(buf); // owner frees after worker is done
  }
}

Loading the Native Library Per Isolate

A DynamicLibrary handle and the function pointers you look up from it are not sendable across isolates. Each isolate that calls native code must open its own DynamicLibrary and resolve its own symbols.

  • The underlying .so/.dylib is loaded once into the process; DynamicLibrary.open in another isolate just gets a fresh handle to the same loaded image.
  • Cache the looked-up function in an isolate-local variable; do not try to pass a Dart Function obtained from lookupFunction to another isolate.
  • On Android the library name is like libnative.so; on iOS/macOS prefer DynamicLibrary.process() for statically linked symbols.
import 'dart:ffi';
import 'dart:io' show Platform;

typedef NativeSum = Int32 Function(Int32 a, Int32 b);
typedef DartSum = int Function(int a, int b);

DynamicLibrary openLib() {
  if (Platform.isAndroid) return DynamicLibrary.open('libnative.so');
  if (Platform.isIOS || Platform.isMacOS) return DynamicLibrary.process();
  return DynamicLibrary.open('libnative.so');
}

// Call this INSIDE each isolate that needs native access.
DartSum resolveSum() {
  final lib = openLib();
  return lib.lookupFunction<NativeSum, DartSum>('native_sum');
}

A Long-Lived Worker Isolate with Ports

For repeated FFI calls, spawning a new isolate each time is wasteful. Instead, spawn one persistent worker with Isolate.spawn and stream requests/responses over ReceivePort/SendPort.

  • The worker opens its native library once on startup and reuses the resolved functions.
  • The main isolate sends a request record (e.g. address + length + a reply port); the worker does the FFI call and replies.
  • This amortizes library-open cost and keeps the main isolate responsive.
import 'dart:isolate';

Future<void> main() async {
  final ready = ReceivePort();
  await Isolate.spawn(_worker, ready.sendPort);
  final SendPort toWorker = await ready.first as SendPort;

  final reply = ReceivePort();
  toWorker.send([21, reply.sendPort]);
  final result = await reply.first;
  print('worker doubled => ' + result.toString());
}

void _worker(SendPort initial) {
  final port = ReceivePort();
  initial.send(port.sendPort); // hand back our inbound port
  port.listen((msg) {
    final value = msg[0] as int;
    final SendPort reply = msg[1] as SendPort;
    reply.send(value * 2); // stand-in for a real FFI call
  });
}

Calling Back into Dart from Native Threads

Sometimes native code (running on its own C thread) needs to notify Dart. You cannot call a Dart function directly from an arbitrary native thread — instead use NativeCallable.listener, which marshals the call onto the isolate that created it via its event loop.

  • NativeCallable.isolateLocal — synchronous, only callable from the same isolate's thread.
  • NativeCallable.listener — asynchronous and thread-safe; safe to invoke from any native thread, the callback is queued to the owning isolate.
  • Call close() on the NativeCallable when done, or it keeps the isolate alive and leaks the trampoline.
import 'dart:ffi';

// C signature: void (*)(Int32) the native side stores and calls later.
typedef ProgressNative = Void Function(Int32);

void onProgress(int percent) {
  print('native reported ' + percent.toString() + '%');
}

void setupCallback() {
  final callback = NativeCallable<ProgressNative>.listener(onProgress);
  // Pass callback.nativeFunction to your C registration function.
  // When finished, release it:
  // callback.close();
  print('callback ptr = ' + callback.nativeFunction.address.toString());
}

Auto-Freeing with NativeFinalizer

Manual try/finally works for scoped allocations, but for native resources tied to the lifetime of a Dart object, use a NativeFinalizer. It runs a native free function when the Dart object becomes unreachable and is garbage-collected.

  • Attach the Dart wrapper to the finalizer with a token pointer to free.
  • The finalizer is best-effort — it is not guaranteed to run promptly (or at all on abrupt exit), so it is a safety net, not a substitute for explicit cleanup.
  • Provide an explicit dispose() and detach the finalizer there to avoid a double free.
import 'dart:ffi';
import 'package:ffi/ffi.dart';

final NativeFinalizer _finalizer = NativeFinalizer(malloc.nativeFree);

class NativeBuffer implements Finalizable {
  final Pointer<Uint8> ptr;
  bool _disposed = false;

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

  void dispose() {
    if (_disposed) return;
    _disposed = true;
    _finalizer.detach(this); // prevent double free
    malloc.free(ptr);
  }
}

Ownership Discipline Across Isolates

The most common native leak in multi-isolate FFI code is unclear ownership: two isolates each think the other will free a buffer, or both free it (a crash). Establish a contract.

  • Single owner: exactly one isolate allocates and frees. Others receive a borrowed address and must not free.
  • Hand-off: if ownership transfers, the sender must not touch or free the pointer after sending the address.
  • Lifetime: the owner must keep the memory alive until it has confirmation (a reply message) that all borrowers are done — never free while a worker may still read it.

Document the contract in code comments; race conditions here surface as use-after-free crashes that are hard to reproduce.

Detecting and Diagnosing Native Leaks

Native heap leaks do not show up in Dart's memory view because the Dart GC never tracked them. You need native tooling and disciplined patterns.

  • Use Android Studio's native memory profiler or Xcode Instruments (Allocations / Leaks) to watch the C heap grow.
  • In tests, wrap allocations and assert that every malloc is matched by a free — a counting allocator wrapper helps.
  • Run a tight loop of your FFI operation; if RSS climbs without leveling off, you have a leak.
  • Prefer calloc when you need zeroed memory so stale bytes don't mask bugs.
import 'dart:ffi';
import 'package:ffi/ffi.dart';

int _live = 0;

Pointer<Uint8> track(int n) {
  _live++;
  return malloc<Uint8>(n);
}

void release(Pointer<Uint8> p) {
  _live--;
  malloc.free(p);
}

void main() {
  for (var i = 0; i < 1000; i++) {
    final p = track(64);
    release(p);
  }
  // Must be zero; non-zero means a leak path skipped release().
  print('outstanding allocations = ' + _live.toString());
}

Quick Check: Sharing FFI Memory Between Isolates

You allocate a Pointer<Uint8> on the main isolate and need a background isolate to read it via FFI. What is the correct, safe way to share it?

Recap: Safe Background FFI

You now have a complete mental model for running FFI off the main isolate without leaking native memory:

  • Move heavy native work off the UI isolate with Isolate.run for one-shots or a persistent Isolate.spawn worker for repeated calls.
  • Share native memory by address — send ptr.address, rebuild with Pointer.fromAddress; never send Pointer or DynamicLibrary objects.
  • Open the native library per isolate; each worker resolves its own function pointers once on startup.
  • Free every allocation: try/finally for scoped use, NativeFinalizer as a GC-time safety net, and explicit dispose() with detach.
  • Enforce single-owner ownership across isolates and keep memory alive until borrowers confirm completion.
  • Use NativeCallable.listener for thread-safe callbacks from native threads, and close() them when done.
  • Profile with native tools (Instruments / Android native profiler) since Dart's GC view cannot see C-heap leaks.

Frequently asked questions

Is the “Background Isolates and Native Memory Management” lesson free?

Yes — the full text of “Background Isolates and Native Memory Management” is free to read here on the web, and the Flutter Mobile Development course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Flutter Mobile Development course, upgrade to CoddyKit PRO.

What will I learn in “Background Isolates and Native Memory Management”?

Run FFI calls off the main isolate and manage native allocations to avoid leaks. You practise Flutter Mobile Development with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Flutter Mobile Development?

No prior experience is required. Flutter Mobile Development on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Background Isolates and Native Memory Management” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Flutter Mobile Development lesson?

Yes. Every Flutter Mobile Development lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Calling C Libraries with dart:ffi
  2. Type-Safe Platform Channels with Pigeon
  3. Writing Custom Platform Plugins for iOS and Android
  4. Background Isolates and Native Memory Management
← Back to Flutter Mobile Development