10LOC
Bunadvanced

Calling a C function from Bun via bun:ffi

Published March 19, 2027

import { dlopen, FFIType, suffix } from "bun:ffi";

const path = `libsqlite3.${suffix}`;

const { symbols } = dlopen(path, {
  sqlite3_libversion: { args: [], returns: FFIType.cstring },
  sqlite3_libversion_number: { args: [], returns: FFIType.i32 },
});

console.log(symbols.sqlite3_libversion()); // e.g. "3.45.1"
console.log(symbols.sqlite3_libversion_number()); // e.g. 3045001

What

dlopen(path, symbols) opens a shared library (.so/.dylib/.dll) and returns typed JavaScript functions for the C symbols you describe — argument and return types included — with no separate binding layer to write or compile.

Why it matters

Calling native code from Node normally means writing a Node-API (N-API) addon: C++ glue code, a native build step (node-gyp or similar), and a .node file that has to be rebuilt per platform and per Node ABI version. bun:ffi skips the glue code entirely — Bun embeds a small C compiler (TinyCC) and JIT-compiles the JS-to-native conversion for each symbol you declare, so calling into an existing shared library needs only a type description, not a build step. Bun's own benchmark puts this roughly 2-6x faster than the equivalent Node-API round-trip, on top of the development-time savings.

How it works

  • suffix resolves to "dylib", "so", or "dll" depending on platform, so `libsqlite3.${suffix}` finds the right file without an if/else on process.platform.
  • dlopen takes the library path and a map of symbol names to { args, returns } type descriptions — this example calls into the system's own SQLite library, which every Bun-supported OS already ships, so there's nothing extra to install or compile to try this.
  • FFIType.cstring on returns tells Bun to convert the native char* result into a JS string automatically; FFIType.i32 for the version number returns a plain number, no conversion needed either way.
  • symbols.sqlite3_libversion() and symbols.sqlite3_libversion_number() are now ordinary JS functions — calling them runs the JIT-compiled native call underneath.

Gotchas

  • bun:ffi is explicitly documented as experimental, with known limitations — Bun's own docs recommend a real Node-API module for anything going to production rather than depending on it long-term.
  • Pointers are represented as plain JS numbers, not BigInt — memory bugs (freeing something still referenced, reading past a buffer) are as possible here as in C itself, and Bun does not manage that memory for you.
  • Passing the wrong FFIType for an argument or return value doesn't throw a friendly JS error — it can silently misinterpret bytes or crash the process, since this is a direct native call, not a safety-checked JS API.

Related

  • Node-API (node:napi via a compiled addon) is the more stable path for native interop that needs to ship to production long-term rather than being called occasionally from a script.
  • WebAssembly is a safer, sandboxed alternative when the native code can be compiled to .wasm instead of linked as a platform-specific shared library.