HomeDocumentationc_006_dart_ffi
c_006_dart_ffi
15

Callbacks: When C Calls Dart Back (and the Thread Problem)

Dart FFI Callbacks: NativeCallable, Thread Safety, and C Calling Dart

March 20, 2026

Reversing the direction

Every post so far has had the same shape: Dart decides when to call C, waits for C to return, and then continues. Dart is in control. C does the work and reports back synchronously.

Many real C libraries don't work that way.

A C audio processing library doesn't wait for you to ask for the next buffer — it tells you when a buffer is ready, by calling a function you registered with it. A C network library doesn't block until a packet arrives — it calls your handler when data comes in. An image processing pipeline doesn't wait for you to poll it — it calls your completion function when the frame is done.

This pattern is everywhere in C: you give C a function pointer — a raw memory address pointing to a block of machine code — and C calls it whenever it has something to tell you. The callback pattern is how event-driven C libraries communicate with their callers.

Dart FFI supports this. The mechanism is NativeCallable — a Dart object that creates a native function pointer you can hand to C, which when invoked eventually runs a Dart function you specified.

But there's a problem. And the problem is threads.

Function pointers: what C actually calls

In C, a function is just code at an address. You can store that address in a variable — a function pointer — and call it later. This is the mechanism all C callbacks are built on:

c
// A function pointer type: takes int32_t, returns void
typedef void (*ProgressCallback)(int32_t percent);

// A function that accepts a callback and calls it during work
void compress_file(
    const char* input_path,
    const char* output_path,
    ProgressCallback on_progress  // C will call this
) {
    for (int i = 0; i <= 100; i += 10) {
        // ... do work ...
        on_progress(i);  // C calling whatever address you gave it
    }
}

When you call compress_file, you pass on_progress a raw function address. When C calls on_progress(i), it jumps to that address and executes whatever code lives there. It doesn't know or care what language that code was written in. If the address points to a Dart function compiled to native ARM code, C will call your Dart function.

NativeCallable creates exactly this: a native function pointer — a raw memory address — that, when called by C, routes the call into Dart.

The thread problem

Here's what makes callbacks fundamentally different from regular FFI calls.

When Dart calls a C function, Dart controls the call. It happens on the Dart isolate's thread. The Dart VM is prepared. C runs, returns, and Dart continues on the same thread. Clean.

When C calls a Dart callback, C controls when the call happens, and from what thread. A C library might call your callback from:

  • The same thread that called the library function (common for synchronous callbacks)
  • A background worker thread the library manages internally (common for async callbacks)
  • A timer thread
  • A network I/O thread

The Dart VM is not thread-safe in the same way a JVM or Go runtime is. You cannot call arbitrary Dart code from any thread — it has to run on the isolate's thread, or be explicitly routed to it. If C calls a Dart function from a thread the Dart VM doesn't know about, bad things happen: crashes, corruption, undefined behavior.

This is why NativeCallable has two distinct constructors — for two different threading scenarios.

NativeCallable.isolateLocal — same thread, synchronous

NativeCallable.isolateLocal() creates a function pointer that must be called from the same thread as the Dart isolate. When C calls this pointer, the Dart callback runs immediately, synchronously, on the same call stack.

This is the right choice when:

  • C calls your callback during the same synchronous call you made into C
  • The callback runs and returns before C's function returns
  • You're certain C never calls the pointer from another thread

The classic example is C's qsort — the standard C sorting function that takes a comparison callback:

c
// Standard C library
void qsort(void* base, size_t n, size_t size,
           int (*compare)(const void*, const void*));

qsort calls compare synchronously, from the same thread, many times, while sorting. It never spawns threads. The callback must return synchronously for qsort to continue.

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

// The C qsort function
typedef _QSortNative = Void Function(
  Pointer<Void> base,
  Size n,
  Size size,
  Pointer<NativeFunction<Int32 Function(Pointer<Void>, Pointer<Void>)>> compare,
);
typedef _QSortDart = void Function(
  Pointer<Void> base,
  int n,
  int size,
  Pointer<NativeFunction<Int32 Function(Pointer<Void>, Pointer<Void>)>> compare,
);

void sortNativeInts(List<int> values) {
  // libc symbols are in the process on both platforms
  final _qsort = DynamicLibrary.process()
      .lookupFunction<_QSortNative, _QSortDart>('qsort');

  // Allocate native array and copy values in
  final array = calloc<Int32>(values.length);

  try {
    for (int i = 0; i < values.length; i++) {
      array[i] = values[i];
    }

    // Create a native function pointer for our Dart comparator
    final comparator = NativeCallable<Int32 Function(Pointer<Void>, Pointer<Void>)>
        .isolateLocal((Pointer<Void> a, Pointer<Void> b) {
      return a.cast<Int32>().value - b.cast<Int32>().value;
    });

    try {
      _qsort(
        array.cast<Void>(),
        values.length,
        sizeOf<Int32>(),
        comparator.nativeFunction, // the raw function pointer C will call
      );
    } finally {
      comparator.close(); // release the native function pointer
    }

    // Copy sorted values back to Dart
    for (int i = 0; i < values.length; i++) {
      values[i] = array[i];
    }
  } finally {
    calloc.free(array);
  }
}

Two things to note:

`comparator.nativeFunction` is the raw Pointer<NativeFunction<...>> — the actual machine-code address. This is what you pass to C.

`comparator.close()` releases the native function pointer. After close(), the address is no longer valid. If C tries to call it after you've closed it — a use-after-free for function pointers — the result is a crash. Always close NativeCallable objects when C no longer needs them.

NativeCallable.listener — any thread, asynchronous

NativeCallable.listener() creates a function pointer that can be safely called from any thread. When C calls this pointer from a background thread, the FFI machinery doesn't attempt to run Dart code immediately on that unknown thread. Instead, it posts a message to the Dart isolate's event queue. The Dart callback runs asynchronously, on the isolate's thread, when the event loop gets to it.

This is the right choice when:

  • C might call the callback from a background thread
  • You don't need to return a value to C (because the call is asynchronous, the return has already happened)
  • You're registering for ongoing events, not a one-time synchronous callback

The constraint: the Dart callback's return type must be `Void`. Because the call is asynchronous — by the time Dart runs the callback, C's call has long since returned — there's no mechanism to send a return value back to C.

A file compression library that reports progress from a background thread:

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

// C API:
// typedef void (*ProgressFn)(int32_t percent);
// typedef void (*CompletionFn)(int32_t error_code);
// void compress_async(const char* input, const char* output,
//                     ProgressFn on_progress, CompletionFn on_complete);

typedef _CompressAsyncNative = Void Function(
  Pointer<Utf8> input,
  Pointer<Utf8> output,
  Pointer<NativeFunction<Void Function(Int32)>> onProgress,
  Pointer<NativeFunction<Void Function(Int32)>> onComplete,
);
typedef _CompressAsyncDart = void Function(
  Pointer<Utf8> input,
  Pointer<Utf8> output,
  Pointer<NativeFunction<Void Function(Int32)>> onProgress,
  Pointer<NativeFunction<Void Function(Int32)>> onComplete,
);

class Compressor {
  static final _compressAsync = /* ... lookupFunction ... */;

  static Future<void> compress(
    String inputPath,
    String outputPath, {
    void Function(int percent)? onProgress,
  }) {
    final completer = Completer<void>();

    // These callables might be called from C's background thread
    // .listener() handles the thread routing safely
    NativeCallable<Void Function(Int32)>? progressCallable;
    NativeCallable<Void Function(Int32)>? completionCallable;

    if (onProgress != null) {
      progressCallable = NativeCallable<Void Function(Int32)>.listener(
        (int percent) {
          onProgress(percent); // runs on Dart isolate thread, safely
        },
      );
    }

    completionCallable = NativeCallable<Void Function(Int32)>.listener(
      (int errorCode) {
        // Clean up both callables — C is done with them
        progressCallable?.close();
        completionCallable?.close();

        if (errorCode == 0) {
          completer.complete();
        } else {
          completer.completeError(
            Exception('Compression failed with error code $errorCode'),
          );
        }
      },
    );

    // Call C — it starts the background work and returns immediately
    using((arena) {
      _compressAsync(
        inputPath.toNativeUtf8(allocator: arena),
        outputPath.toNativeUtf8(allocator: arena),
        progressCallable?.nativeFunction ??
            Pointer.fromAddress(0), // null pointer if no progress callback
        completionCallable!.nativeFunction,
      );
    });

    return completer.future;
  }
}

Usage from Flutter:

dart
await Compressor.compress(
  '/storage/input.mp4',
  '/storage/output.mp4',
  onProgress: (percent) {
    setState(() => _progress = percent / 100.0);
  },
);

The Future returned by compress() resolves when C calls the completion callback. The progress callbacks arrive on the Dart isolate's event loop, where you can safely call setState(). No thread synchronization required, no mutexes, no race conditions — the .listener() mechanism routes everything correctly.

The lifetime problem: NativeCallable must outlive C's use of it

This is the subtlest danger with callbacks, and it's one that the type system cannot catch.

When you call callable.close(), the native function pointer is invalidated. If C calls it after that point, you get a crash — or worse, C jumps to whatever code now occupies that memory address.

In the qsort example, the sequencing is clear: qsort is synchronous, it finishes before our try block's finally runs, and we close the callable after qsort returns. Safe.

In the async compression example, the sequencing is more subtle. We close the callables inside the completion callback — after C tells us it's done using them. This is the correct pattern: let C tell you when it's done, then close.

The dangerous antipattern:

dart
// ❌ WRONG — closing before C is done with the pointer
final callable = NativeCallable<Void Function(Int32)>.listener(onProgress);
_startAsyncWork(callable.nativeFunction);
callable.close(); // C hasn't called this yet! Use-after-free.

The safe pattern:

dart
// ✅ CORRECT — close inside the callback or in the completion handler
NativeCallable<Void Function(Int32)>? callable;
callable = NativeCallable<Void Function(Int32)>.listener((int result) {
  handleResult(result);
  callable?.close(); // C just called this — it's done with the pointer
});
_startAsyncWork(callable.nativeFunction);

The circular reference (the callback closing callable, which holds the callback) is fine here — Dart handles the closure correctly. callable is captured by reference in the closure, so when the closure runs, it sees the assigned value.

Returning values from isolateLocal callbacks

Unlike .listener() callbacks, .isolateLocal() callbacks can return values to C. The Dart function's return type maps to the native return type:

dart
// C expects a comparison function: int (*)(const void*, const void*)
final callable = NativeCallable<Int32 Function(Pointer<Void>, Pointer<Void>)>
    .isolateLocal((Pointer<Void> a, Pointer<Void> b) {
  // This return value goes directly back to C's qsort
  return a.cast<Int32>().value.compareTo(b.cast<Int32>().value);
});

The return value is marshalled from Dart's int to C's int32_t automatically, just like regular FFI return values. Because .isolateLocal() is synchronous — C waits for Dart to return — the value arrives at C before C continues.

With .listener(), the Dart function must return void. C has already continued by the time Dart runs the callback. There's no mechanism to deliver a return value across that asynchronous gap.

Callbacks and Isolate.run — the tension

In Post 1, we established that CPU-intensive native calls should run on a background isolate via Isolate.run() to avoid blocking the UI thread. That advice is correct for simple synchronous calls. It creates a complication for callbacks.

When you do:

dart
final result = await Isolate.run(() {
  // This runs in a temporary isolate
  return _expensiveCFunction(input);
});

The native function runs in the temporary isolate's thread. If that native function tries to call a Dart callback via .listener(), the listener posts to its current isolate — the temporary one. But the Dart code that processes the event might be in the main isolate, which is different.

For Isolate.run() with callbacks, either:

  • Use .isolateLocal() if the callbacks are synchronous (C calls them on the same thread)
  • Move the entire long-running operation — including callback setup — into the background isolate using Isolate.spawn() (more control than Isolate.run()) and communicate results back via ports
  • Or restructure the C library interaction to separate the CPU-heavy work (no callbacks) from the event-driven parts (callbacks on main isolate)

The cleanest pattern for long-running async C libraries: keep the NativeCallable setup and the event handling on the main isolate, run only pure computation (no callbacks) on background isolates.

A realistic pattern: an event-driven C library

Let's look at a more complete example: wrapping a C network monitoring library that calls callbacks for network events.

c
// network_monitor.h
typedef void (*PacketCallback)(
    const char* src_ip,
    const char* dst_ip,
    uint16_t    src_port,
    uint16_t    dst_port,
    uint32_t    bytes
);

typedef void (*ErrorCallback)(int32_t error_code, const char* message);

// Start monitoring — returns a handle, calls callbacks on its internal thread
void* network_monitor_start(
    PacketCallback on_packet,
    ErrorCallback  on_error
);

// Stop monitoring and release the handle
void network_monitor_stop(void* handle);

The Dart wrapper:

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

typedef _PacketCallbackNative = Void Function(
  Pointer<Utf8> srcIp, Pointer<Utf8> dstIp,
  Uint16 srcPort, Uint16 dstPort, Uint32 bytes,
);
typedef _ErrorCallbackNative = Void Function(Int32 errorCode, Pointer<Utf8> message);
typedef _StartNative = Pointer<Void> Function(
  Pointer<NativeFunction<_PacketCallbackNative>>,
  Pointer<NativeFunction<_ErrorCallbackNative>>,
);
typedef _StopNative = Void Function(Pointer<Void> handle);

class PacketEvent {
  final String srcIp;
  final String dstIp;
  final int srcPort;
  final int dstPort;
  final int bytes;

  const PacketEvent({
    required this.srcIp, required this.dstIp,
    required this.srcPort, required this.dstPort,
    required this.bytes,
  });
}

class NetworkMonitor {
  static final _start = _lib.lookupFunction<_StartNative, /* ... */>();
  static final _stop  = _lib.lookupFunction<_StopNative, /* ... */>();

  final _packetController = StreamController<PacketEvent>.broadcast();
  final _errorController  = StreamController<String>.broadcast();

  Stream<PacketEvent> get packets => _packetController.stream;
  Stream<String>      get errors  => _errorController.stream;

  late final NativeCallable<_PacketCallbackNative> _packetCallable;
  late final NativeCallable<_ErrorCallbackNative>  _errorCallable;
  late final Pointer<Void> _handle;

  NetworkMonitor() {
    // Both callbacks may arrive from C's internal network thread
    // .listener() routes them safely back to our Dart isolate
    _packetCallable = NativeCallable<_PacketCallbackNative>.listener(
      (srcIp, dstIp, srcPort, dstPort, bytes) {
        _packetController.add(PacketEvent(
          srcIp: srcIp.toDartString(),
          dstIp: dstIp.toDartString(),
          srcPort: srcPort,
          dstPort: dstPort,
          bytes: bytes,
        ));
      },
    );

    _errorCallable = NativeCallable<_ErrorCallbackNative>.listener(
      (errorCode, message) {
        _errorController.add('[$errorCode] ${message.toDartString()}');
      },
    );

    _handle = _start(
      _packetCallable.nativeFunction,
      _errorCallable.nativeFunction,
    );
  }

  void dispose() {
    // Tell C to stop — it will not call our callbacks after this returns
    _stop(_handle);

    // Now safe to close the callables
    _packetCallable.close();
    _errorCallable.close();

    _packetController.close();
    _errorController.close();
  }
}

Usage in a Flutter widget:

dart
class NetworkWidget extends StatefulWidget { /* ... */ }

class _NetworkWidgetState extends State<NetworkWidget> {
  late NetworkMonitor _monitor;
  final List<PacketEvent> _events = [];

  @override
  void initState() {
    super.initState();
    _monitor = NetworkMonitor();
    _monitor.packets.listen((event) {
      setState(() => _events.add(event));
    });
  }

  @override
  void dispose() {
    _monitor.dispose(); // stops C, closes callables, closes streams
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => ListView.builder(
    itemCount: _events.length,
    itemBuilder: (ctx, i) => ListTile(
      title: Text('${_events[i].srcIp} → ${_events[i].dstIp}'),
      subtitle: Text('${_events[i].bytes} bytes'),
    ),
  );
}

The Stream abstraction is the right layer here. C events arrive via .listener() callbacks, get transformed into typed Dart objects, and flow into a stream. Flutter widgets listen to the stream and update normally. The FFI machinery is completely hidden behind a clean API.

The full picture: Dart ↔ C communication

Take a step back and look at what this series has built:

Post 1 established why FFI exists and how to make a basic synchronous call — Dart calls C, C returns a value.

Post 2 handled complex data crossing the boundary in one direction — Dart prepares strings and byte buffers, hands them to C, reads results back. Ownership as an explicit contract.

Post 3 handled structured data — C structs and their exact byte layout, the alignment rules that must match between C and Dart or produce silent corruption.

Post 4 reversed the direction — C calling Dart, the thread routing problem, NativeCallable.isolateLocal for synchronous callbacks on the same thread, NativeCallable.listener for asynchronous callbacks from any thread.

You now have the complete vocabulary for Dart ↔ C communication in both directions. The only remaining piece is putting it together with a real library — not a toy example, but a battle-tested C library with a real API, real documentation, and real edge cases.

What comes next

Post 5 is the culmination: integrating libsodium — a production cryptography library — into a Flutter app from scratch. No training wheels. We'll download the library, write the CMakeLists.txt for Android and iOS, use ffigen to generate bindings from the header files, wrap the bindings in a clean Dart API, and use the result to do authenticated encryption in a real Flutter feature.

Everything from the previous posts will be needed: Pointer<Uint8> for plaintext and ciphertext buffers, Pointer<Utf8> for error messages, structs for key material, Arena for managing the multiple allocations a crypto operation requires. It all comes together.

Ready to build your app?

Turn your ideas into reality with our expert mobile app development services.