Dart 80 🎯 Native C Extensions vs Dart FFI Performance Benchmarks
The question of whether to write a native C extension or use Dart FFI is not a question about whether native code is faster than Dart. In a pure computational sense, C is faster for the same algorithm because it compiles to machine code without the Dart VM’s overhead. The real question is whether the cost of crossing the boundary between Dart and C is small enough that the native computation’s speed advantage survives the crossing. The answer depends on the workload: a single FFI call that processes a large buffer pays the boundary cost once and reaps the native speed for the entire computation, while a loop that calls C once per element pays the boundary cost on every iteration and can end up slower than pure Dart.
The benchmarks show a consistent crossover point. The FFI boundary cost is roughly fixed—on the order of tens to hundreds of nanoseconds per call—while Dart’s compute time grows with the amount of work. Below the crossover, pure Dart wins because it pays no boundary cost. Above it, native code wins because its throughput advantage dominates the fixed entry cost. A physics simulation benchmark placed the crossover at 20,000 to 30,000 particles per frame; below that, Dart was faster, and above it, one batched FFI call started to pay for itself . The benchmark also showed that C and Rust were within 2–5% of each other at the same particle count, and that Rust’s advantage grew with substep count because the optimizer eliminated redundant work across iterations .
Key point: Native C is faster for the computation itself, but FFI boundary crossings cost tens to hundreds of nanoseconds each. The decision is not “native vs Dart” but “how many boundary crossings does the workload require?” Batch the work into a few large FFI calls and native code wins above the crossover; call FFI once per element and the boundary cost can erase the native advantage.
Why the boundary cost is the deciding factor
The fixed-entry-cost problem. Every FFI call transitions from the Dart mutator thread state to the native thread state, performs a safepoint check, executes the C function, and transitions back. The isLeaf: true annotation eliminates the safepoint check and the thread state transition for functions that do not call back into Dart and do not block, which is why adding it to 33 bindings in a SQLite wrapper produced a 5–19% improvement across benchmarks, with the largest gains on small result sets where the FFI overhead is a larger fraction of total wall time . The boundary cost is not huge—roughly 50 nanoseconds saved per call in that case—but it compounds across thousands of calls.
The copy-tax problem. Data passed between Dart and C is not shared by default. A Uint8List must be copied into a C memory buffer before the C function can read it, and the result must be copied back. For a 10 MB payload transferred 100 times, the MethodChannel approach took roughly 4,200 milliseconds, a Dart isolate approach took 1,800 milliseconds, and a zero-copy approach that mapped the C pointer directly into a Uint8List took under 10 milliseconds . The copy is not the FFI call itself; it is the allocation and serialization around it. Zero-copy is the technique that eliminates it, but it requires the C side to own the buffer and the Dart side to read it in place.
The batching problem. A SQLite wrapper’s select() hot loop made roughly 16 FFI calls per row: one sqlite3_step, six sqlite3_column_type, six value reads, and about three text length reads. For 5,000 rows, that was 80,000 FFI calls. A batch function that stepped one row and filled a pre-allocated native struct array with all column values in one call reduced the crossing to one per row, and produced a 9–21% improvement across benchmarks, with the largest gains on wide tables where more FFI calls were saved per row . A later experiment that tried to batch 64 rows per call was rejected because SQLite’s text pointers are only valid until the next step, forcing a copy into a buffer, and the copy cost exceeded the FFI savings .
The isLeaf annotation problem. A regular FFI call performs a full thread state transition on every invocation. The isLeaf: true annotation eliminates the transition and the safepoint check, but it requires that the C function does not call back into Dart and does not block for extended time. For pure computation against in-process memory—SQLite column reads, hashing, math—this is safe, and the savings are consistent . For a function that calls back into Dart or waits on I/O, isLeaf would be incorrect.
a. When native C wins: the batched, compute-heavy workload
The native advantage is largest when the boundary is crossed few times and the computation inside is substantial. A CSV parser that processes 100,000 rows in a single C call is the canonical example. A BI tool that moved from MethodChannel to dart:ffi for a C++ CSV parser reported a 50-fold performance improvement, because the MethodChannel path serialized the entire 100,000-row string and the result list, adding seconds of serialization and deserialization overhead, while the FFI path passed a pointer and processed in place . The benchmark from the physics simulation shows the same pattern: at 65,536 particles and 8 substeps, C and Rust were within 2–5% of each other and both dominated Dart because the per-frame computation was large enough to amortize the fixed entry cost .
The OpenSSL bindings package provides another data point. Signing a compact JWS with RSA-2048 through the native OpenSSL bindings was roughly 4 times faster than the pure-Dart path, and verifying was roughly 8 times faster . The computation is large enough—modular exponentiation with a 2048-bit key—that the FFI boundary cost is negligible.
b. When pure Dart wins: the fine-grained, low-compute workload
The pure Dart advantage is largest when the workload is small or when the boundary would be crossed frequently. A Postgres driver author tested a native C-backed driver against a pure-Dart driver and found that the pure-Dart driver kept up under heavy load and was sometimes faster. The reason was isolates: the pure-Dart driver spread the work across isolates, using every CPU core cleanly, and the native driver’s advantage disappeared because the bottleneck was not raw computation but the database round-trip and the isolate parallelism . The lesson is that native code is not a rescue from a slow language; it is a tool for the cases where C is genuinely better.
The per-cell FFI overhead is the other case. A sqlite3 wrapper that crossed the boundary per column per row paid 10–20 nanoseconds per call, and for a 5,000-row, 6-column query that was 30,000+ FFI calls . A binary buffer approach that packed all rows into one C call was not faster for building maps, because the C-side encoding saved the FFI overhead but the Dart-side decoding added it back—two passes over the data were slower than one . The conclusion was that direct per-cell FFI with one-pass map construction was faster at all practical sizes, which is a case where the fine-grained FFI was the better choice because the alternative added a second pass.
c. The isLeaf and batching techniques that shift the crossover
The crossover is not fixed. It can be shifted by reducing the cost per boundary crossing. The isLeaf annotation is the cheapest improvement: one annotation per binding, no code restructuring, and a consistent 5–19% improvement in the SQLite wrapper’s benchmarks . The batching technique is more invasive: a C function that processes a whole buffer in one call, and a Dart side that allocates the buffer once and reads the result from native memory. The SQLite batch experiment reduced crossings from 16 per row to 1 per row and improved wall time by 9–21% . The zero-copy technique is the most invasive: the C side owns the buffer and the Dart side maps it with Pointer.asTypedList(), which eliminates the copy entirely and produced a 4,200 ms to under 10 ms improvement in the 10 MB payload benchmark .
The decision framework is therefore not “native vs Dart” but “how many crossings, how much compute per crossing, and how much data is copied per crossing.” A workload with few crossings, substantial compute, and zero or one copy is a native win. A workload with many crossings, trivial compute, or large copies is a Dart win or a signal to restructure the boundary.
Complete Example Session
The following session demonstrates the three boundary-cost regimes: a per-element FFI call that is slower than pure Dart, a batched FFI call that is faster, and an isLeaf annotation that reduces the per-call cost.
// ============================================
// PART 1: THE C FUNCTIONS
// ============================================
// A per-element add function, called once per element
// The boundary cost is paid on every call
// int add_one(int x) { return x + 1; }
//
// A batched sum function that processes an entire array
// The boundary cost is paid once for the whole array
// long sum_array(int* values, int count) {
// long total = 0;
// for (int i = 0; i < count; i++) total += values[i];
// return total;
// }
// ============================================
// PART 2: THE DART BINDINGS
// ============================================
import 'dart:ffi';
import 'package:ffi/ffi.dart';
// Bind the per-element function
// isLeaf is not used because the function is trivial
final addOne = DynamicLibrary.process()
.lookupFunction<Int32 Function(Int32), int Function(int)>('add_one');
// Bind the batched function with isLeaf
// isLeaf eliminates the thread state transition and safepoint check
final sumArray = DynamicLibrary.process()
.lookupFunction<Int64 Function(Pointer<Int32>, Int32),
int Function(Pointer<Int32>, int)>('sum_array', isLeaf: true);
// ============================================
// PART 3: THE BENCHMARKS
// ============================================
void main() {
final n = 100000;
// Benchmark 1: per-element FFI, 100,000 crossings
final sw1 = Stopwatch()..start();
var total = 0;
for (var i = 0; i < n; i++) {
total += addOne(i);
}
sw1.stop();
// Benchmark 2: batched FFI, one crossing
// Allocate the array once, fill it, call once
final ptr = calloc<Int32>(n);
for (var i = 0; i < n; i++) ptr[i] = i;
final sw2 = Stopwatch()..start();
final batchedTotal = sumArray(ptr, n);
sw2.stop();
calloc.free(ptr);
// Benchmark 3: pure Dart loop, no FFI
final sw3 = Stopwatch()..start();
var dartTotal = 0;
for (var i = 0; i < n; i++) dartTotal += i;
sw3.stop();
print('Per-element FFI: ${sw1.elapsedMilliseconds} ms ($total)');
print('Batched FFI: ${sw2.elapsedMilliseconds} ms ($batchedTotal)');
print('Pure Dart: ${sw3.elapsedMilliseconds} ms ($dartTotal)');
}
The three parts cover the C functions, the Dart bindings with and without isLeaf, and the benchmarks. What the session demonstrates is that the per-element FFI benchmark pays the boundary cost 100,000 times, the batched benchmark pays it once, and the pure Dart benchmark pays it never. The batched FFI is the only native path that has a chance of beating pure Dart for this workload, and the isLeaf annotation reduces the cost of the single crossing.
Quick Reference
Boundary Cost
| Mechanism | Approximate Cost | Source |
|---|---|---|
| FFI call (regular) | Tens to hundreds of ns | |
FFI call (isLeaf) | Lower, no safepoint | |
| MethodChannel | 12–50 ms per call | |
| Isolate message | Copy cost, ms for large data |
Crossover
| Workload Size | Winner | Source |
|---|---|---|
| < 20k–30k particles | Pure Dart | |
| > 20k–30k particles | Native FFI (batched) | |
| Large buffer, one call | Native FFI | |
| Per-element calls | Pure Dart or restructure |
Optimization Techniques
| Technique | Effect | Source |
|---|---|---|
isLeaf: true | 5–19% improvement | |
| Batch per row | 9–21% improvement | |
| Zero-copy pointer | ~4,200 ms → <10 ms | |
| Isolate parallelism | Pure Dart keeps up with native |
Real-World Speedups
| Workload | Speedup | Source |
|---|---|---|
| RSA-2048 JWS sign | ~4× | |
| RSA-2048 JWS verify | ~8× | |
| CSV parse (100k rows) | 50× | |
| BlurHash decode | 20× |
Best Practices
✅ Do This:
// Annotate pure C functions with isLeaf
final f = lib.lookupFunction<..., ...>('f', isLeaf: true);
// Batch the work into one call
final result = processBuffer(ptr, count);
// Allocate the buffer once and reuse it
final ptr = calloc<Int32>(n);
// ... fill, call, read ...
calloc.free(ptr);
❌ Don’t Do This:
// Call FFI once per element in a hot loop
for (var i = 0; i < n; i++) total += addOne(i); // boundary cost dominates
// Copy large data through MethodChannel
// Serialization and deserialization dominate
// Use isLeaf on a function that calls back into Dart
// The thread state transition is required
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| FFI slower than Dart | Per-element calls | Batch the work |
| Large data is slow | Copy tax | Zero-copy or transfer |
| Segfault | No type checking | Use ffigen |
| Memory leak | C memory not freed | calloc.free or Arena |
| UI jank | Long FFI blocks isolate | Move to isolate |
Real-World Examples
1. Per-element FFI (slow)
for (var i = 0; i < n; i++) total += addOne(i);
2. Batched FFI (fast)
final total = sumArray(ptr, n);
3. isLeaf annotation
final f = lib.lookupFunction<...>('f', isLeaf: true);
4. Zero-copy view
final list = ptr.asTypedList(length);
5. MethodChannel (slow)
final result = await channel.invokeMethod('parse', data);
6. Isolate parallelism
final results = await Future.wait(isolates.map((i) => i.compute()));
7. RSA sign (4x)
final signature = nativeSign(key, data);
8. BlurHash (20x)
final pixels = nativeDecode(hash, width, height);
9. ffigen binding
ffigen:
output: 'bindings.dart'
headers:
entry-points:
- 'lib.h'
10. Arena allocation
final arena = Arena();
final ptr = arena<Int32>(n);
arena.release();
Visual
The Crossover
┌──────────────────────────────────────────────┐
│ Time per frame │
│ │ │
│ Pure │ / │
│ Dart │ / │
│ │/ crossover │
│ x─────────────────────────── │
│ /│ │
│ Native/ │ fixed entry cost, slower growth │
│ / │ │
│ / │ │
│ ────┴───┴───────────────▶ particles │
│ 20k 30k 65k+ │
└──────────────────────────────────────────────┘
Boundary Cost
┌──────────────────────────────────────────────┐
│ FFI call: tens of ns │
│ MethodChannel: 12–50 ms │
│ Copy tax: proportional to data size │
└──────────────────────────────────────────────┘
Optimization Shift
┌──────────────────────────────────────────────┐
│ Per-element: 100,000 crossings │
│ │ │
│ ▼ │
│ Batched: 1 crossing │
│ │ │
│ ▼ │
│ isLeaf: lower per-call cost │
│ │ │
│ ▼ │
│ Zero-copy: no data copy │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| FFI call cost | Tens to hundreds of ns |
| MethodChannel cost | 12–50 ms per call |
| Crossover | ~20k–30k particles per frame |
isLeaf improvement | 5–19% |
| Batching improvement | 9–21% |
| Zero-copy improvement | ~4,200 ms → <10 ms |
| RSA sign speedup | ~4× |
| RSA verify speedup | ~8× |
| CSV parse speedup | 50× |
Key takeaways:
- Native C is faster for the computation, not for the boundary. The FFI call has a fixed cost, and the native advantage only appears when the computation is large enough to amortize that cost .
- The crossover is real and measurable. A physics simulation placed it at 20,000–30,000 particles per frame; below that, pure Dart was faster, and above it, batched FFI won .
- Batching shifts the crossover in native’s favor. Reducing 16 FFI calls per row to 1 per row produced a 9–21% improvement, with the largest gains on wide tables .
isLeaf: trueis the cheapest optimization. It eliminates the thread state transition and safepoint check for pure C functions, producing a consistent 5–19% improvement with no code restructuring .- Zero-copy eliminates the copy tax. Mapping a C pointer directly into a
Uint8Listturned a 4,200 ms transfer into under 10 ms for a 10 MB payload . - Pure Dart can keep up when the bottleneck is not computation. A pure-Dart Postgres driver matched or beat a native C-backed driver under heavy load because the isolate model used every core cleanly .
Remember: The decision between native C and Dart FFI is not about which language is faster. It is about how many times the boundary is crossed, how much compute happens on the other side, and how much data is copied. A single FFI call that processes a large buffer wins; a per-element call in a hot loop loses. The isLeaf annotation, the batching pattern, and the zero-copy technique are the tools that shift the crossover toward native, but they do not eliminate the boundary cost—they reduce it. The benchmark that matters is the one on your target device with your actual workload, because the crossover is hardware-dependent and workload-dependent. Start with Dart, measure, and reach for native only when the measurement says the computation is large enough to pay for the crossing.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!