FFI: calling a prebuilt shared library from JS with koffi — and who owns the memory
FFI calls an already-compiled shared library from JS — no C glue, no compiler — by declaring the C prototype and letting libffi build the call at runtime. You cross into native memory the GC cannot see, so a wrong signature or an unfreed pointer segfaults the process.
A team needed three functions from a vendor’s C SDK — a .so with no JavaScript bindings — and nobody wanted to own a C++ addon. So they reached for ffi-napi: declare the function signatures in JS, point it at the library, done in an afternoon. It worked on Node 16. Then a Node upgrade landed and npm install died rebuilding ref-napi’s native binary against the new ABI — the FFI stack is itself a compiled addon. They pinned Node and moved on. Months later the real bill came due: one signature declared a 64-bit device handle as 'int', so every returned pointer was silently truncated to 32 bits. Most of the time the high bits were zero and it worked. Under load, with the heap mapped higher, it wasn’t — and the process took a SIGSEGV straight to the ground, no stack trace, no catch, mid-request. The fix was never “write more C.” It was understanding what FFI actually does: declare the exact ABI, decide who frees what, and stop running an unmaintained binding.
What FFI is — and how it differs from a native addon
In ten minutes you’ll know exactly why that SIGSEGV from the hook happened, how FFI works at the ABI level, and the three memory rules that separate a production-safe integration from a process-killing time bomb.
The previous lessons built a native addon: you write C/C++ glue against Node-API, compile it to a .node binary, and require it. FFI (Foreign Function Interface) skips the glue entirely. You take an already-compiled shared library — libfoo.so on Linux, .dylib on macOS, .dll on Windows — declare the prototype of the function you want in JavaScript, and a library marshals the call at runtime. No C source of your own, no compiler at install, no .node to build: just open the library and describe the signature.
import koffi from 'koffi';
const libm = koffi.load('libm.so.6'); // dlopen the shared library
const pow = libm.func('double pow(double, double)'); // declare the C prototype
pow(2, 10); // 1024 — libffi packs the doubles per the ABI, calls, reads the resultThat libm.func('double pow(double, double)') line is the whole idea: a string that is the C signature. There is no compiler verifying it against libm’s real pow. The declaration is a contract you alone enforce — and that is the central tradeoff against a native addon, where the C compiler type-checks your glue. FFI buys you “no toolchain, wrap an existing library in minutes” by selling you “get one type wrong and there is no one to tell you.”
libffi: building a call frame at runtime
Under every FFI binding sits libffi. A normal compiled call has its argument layout baked in at compile time — the compiler knows to put the first integer in rdi, the first float in xmm0, and so on for the platform’s calling convention (System V AMD64 on Linux/macOS, the Microsoft x64 ABI on Windows). FFI does not have that luxury: it learns the signature only at runtime, from your declaration. So libffi constructs the call frame dynamically — it reads your declared types, places each argument in the right register or stack slot for the ABI, jumps through a trampoline into the library function, and reads the return value back out of the return register.
The cost is that dynamic step. Each FFI call pays a trampoline plus per-argument marshalling — on the order of tens of nanoseconds on top of the C function’s own work. For a handful of coarse calls per request that is invisible. For a hot loop making millions of tiny calls it dominates, and a compiled Node-API addon — whose calls are direct, with no runtime frame-building — wins decisively. FFI’s sweet spot is coarse-grained calls into an existing library, not a chatty inner loop.
koffi vs ffi-napi: pick the maintained one
There are two FFI lineages in Node, and the choice is not close in 2025.
The classic stack is ffi-napi + ref-napi — the descendants of node-ffi. Its fatal property is the one from the hook: it is itself a native addon. Installing it compiles C, so you inherit the entire node-gyp/prebuild/ABI matrix from the last lesson — the very build pain FFI was supposed to avoid — plus breakage every time a Node major shifts the ABI. It is now effectively unmaintained. Do not start new work on it.
koffi is the modern answer. It ships prebuilt binaries for common platforms (no node-gyp on install), is actively maintained, runs measurably faster, and has first-class support for structs, pointers, output parameters, callbacks, and disposable types. For new FFI work, koffi is the senior default; treat ffi-napi as legacy you migrate off.
Memory ownership: the boundary the GC will not cross
This is where FFI bites, and it is worth stating as a hard rule: JavaScript’s garbage collector cannot see native memory, and C cannot see the JS heap. They are two separate worlds with no shared bookkeeping. Three consequences you must manage by hand:
- C allocations are yours to free. If a function returns a
malloc’d pointer (achar*string, a struct), the GC will never reclaim it — you must call the library’s matching free function yourself. Forget it and you have a classic C memory leak, in a Node process, that no heap snapshot will explain because it is off-heap. - Buffers you pass must outlive the call. When you hand C a pointer into a JS
Bufferor typed array, that memory is only safe for as long as the JS object is alive and unmoved. If C stores the pointer and uses it after the call returns (an async callback, a retained handle), you must keep a live JS reference so the GC neither collects nor relocates it — otherwise C dereferences freed memory. - Output parameters need explicit allocation. Many C functions fill a caller-provided buffer (
int read(fd, void* buf, size_t n)). You allocate it (koffi.alloc), pass the pointer, then decode what C wrote back.
// C: char* make_greeting(const char* name); // returns a malloc'd string
// void free_greeting(char* p); // caller must free it
const make = lib.func('char* make_greeting(const char*)');
const free = lib.func('void free_greeting(char*)');
const ptr = make('Ada'); // C malloc'd this — GC has no idea
const text = koffi.decode(ptr, 'char*'); // copy the bytes into a JS string
free(ptr); // YOU free it, or it leaks forever▸Why this works
Why is this so much sharper than a normal Node bug? Because the failure is not a JavaScript exception. Dereferencing a freed or moved pointer is a SIGSEGV — the OS kills the process. There is no try/catch that saves you, no stack trace pointing at the offending line, and the crash frequently surfaces far from the real bug, whenever the freed page is next touched. An undrained stream leaks gracefully; a mismanaged FFI pointer takes the whole server down without a word. The discipline is exactly C’s: pair every allocation with a free, and keep referenced anything the native side still holds.
Structs, callbacks, and the cost of a wrong type
Three more places the contract must be exact:
Structs. You declare the layout (koffi.struct({ x: 'int', y: 'double' })), and the field order, types, and alignment/padding must match the C definition byte-for-byte. Get the padding wrong and every field after it reads from the wrong offset — garbage, not a crash, which is worse to debug.
Callbacks. To let C call back into JS (a comparator, an event handler) you register a JS function as a C function pointer. Two hazards: the JS function must stay alive while C holds the pointer (drop the reference and GC collects it under C’s feet), and if the library invokes your callback on a non-JS thread, calling into V8 from there crashes — you need koffi’s async/registered-callback handling, not a raw synchronous one.
Wrong scalar types. The hook’s bug generalizes: declaring 'int' where C returns a 64-bit size_t, long, or pointer truncates the value; declaring a signed type for an unsigned one flips large values negative. None of this is checked. The declaration is the only source of truth, and it is yours to get right.
A vendor ships a closed-source native library (libvendor.so) with three functions you must call a few times per request from a Node service. No source, no JS bindings. Which approach?
Why does koffi FFI need no C compiler at install, while a Node-API addon does?
A C function returns a malloc'd char* that you read into a JS string. What is the risk, and the correct handling?
- 01How does FFI call native code without a compiler, and what does it trade away versus a Node-API addon?
- 02State the memory-ownership rules for crossing the FFI boundary, and why getting them wrong is uniquely dangerous.
FFI lets Node call into an already-compiled shared library — a vendor .so, a system library — straight from JavaScript, with no C glue of your own and no compiler at install: you dlopen the library and declare the C function’s prototype, and a binding builds the call at runtime. Under the hood libffi reads your declared types and constructs the call frame dynamically, placing arguments per the platform ABI, trampolining into the function, and marshalling the return — which is why each call costs a little more than a compiled addon’s direct call and why FFI fits coarse calls, not a chatty hot loop. The binding you choose matters: the classic ffi-napi/ref-napi stack is itself a native addon, so it drags in the whole node-gyp/prebuild/ABI-breakage problem and is now effectively unmaintained, while koffi ships prebuilt, stays maintained, runs faster, and handles structs, pointers, callbacks, and disposables — so koffi is the senior default. The real discipline is memory: the GC cannot see what C allocates and C cannot see the JS heap, so you free every pointer the library mallocs, keep any Buffer you pass alive (and unmoved) as long as C holds it, allocate output buffers explicitly, and declare every type exactly — because a wrong signature or a mismanaged pointer is not a catchable JavaScript error but a SIGSEGV that takes the whole process down without a stack trace. FFI is the fastest way to wrap an existing native library, and the easiest way to crash a server if you forget you are now writing C. Now when you reach for FFI against a vendor SDK, you’ll check the three things first: is the signature exact (especially pointer widths), who frees the returned memory, and does any callback fire off the JS thread — because those are the three places where a SIGSEGV waits.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.