A while back I was writing a Python script that was doing some number-heavy data processing. Pure Python was too slow, so I reached for Cython — which compiles annotated Python down to C. It’s the same trick NumPy plays: the Python layer is just a thin wrapper, and the expensive work happens in compiled native code. The speed difference is often 10–100x.
I’ve been using Bun as my daily runtime for TypeScript, and I ran into the same kind of situation: a hot loop that JavaScript just wasn’t cutting it for. Turns out Bun has a built-in answer for this: bun:ffi.
The idea is identical to the Python/Cython pattern. Keep your application logic in TypeScript, and delegate the CPU-intensive parts to a compiled Rust library. The boundary between them is the C ABI — the universal handshake that lets different languages call into each other.
Note:
bun:ffiis currently experimental. It works well for most cases, but Bun themselves recommend Node-API modules for production use.
Why Rust specifically?
You could use C, C++, Zig, or anything that compiles to a C-compatible shared library. I prefer Rust here because:
- Memory safety without a garbage collector — you’re not introducing segfault risk
cargomakes building shared libraries straightforward- The performance is on par with C
A real example: batch number crunching
Say you have an array of a million floats and need to compute a normalized score for each one. In TypeScript:
function normalize(values: number[]): number[] {
const min = Math.min(...values);
const max = Math.max(...values);
return values.map((v) => (v - min) / (max - min));
}
Fine for small arrays. Painful at scale. Here’s how to push that into Rust.
Step 1: Write the Rust library
Create a new Rust project as a dynamic library:
cargo new --lib fast_math
cd fast_math
In Cargo.toml, set the crate type to cdylib:
[lib]
crate-type = ["cdylib"]
Write the function in src/lib.rs. It needs to be extern "C" and #[no_mangle] so Bun can find it by name:
// src/lib.rs
#[no_mangle]
pub extern "C" fn normalize(
input: *const f64,
output: *mut f64,
len: usize,
) {
if len == 0 {
return;
}
let input_slice = unsafe { std::slice::from_raw_parts(input, len) };
let output_slice = unsafe { std::slice::from_raw_parts_mut(output, len) };
let min = input_slice.iter().cloned().fold(f64::INFINITY, f64::min);
let max = input_slice.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let range = max - min;
if range == 0.0 {
output_slice.fill(0.0);
return;
}
for (i, &v) in input_slice.iter().enumerate() {
output_slice[i] = (v - min) / range;
}
}
Compile it in release mode:
cargo build --release
This produces target/release/libfast_math.dylib on macOS, .so on Linux, or .dll on Windows.
Step 2: Load it from Bun
import { dlopen, FFIType, suffix, ptr } from "bun:ffi";
const lib = dlopen(`./target/release/libfast_math.${suffix}`, {
normalize: {
args: [FFIType.ptr, FFIType.ptr, FFIType.u64],
returns: FFIType.void,
},
});
export function normalize(values: number[]): Float64Array {
const input = new Float64Array(values);
const output = new Float64Array(values.length);
lib.symbols.normalize(ptr(input), ptr(output), BigInt(values.length));
return output;
}
Call it like any regular function:
const data = Array.from({ length: 1_000_000 }, () => Math.random() * 1000);
const result = normalize(data);
console.log(result[0]); // a float between 0 and 1
The pointer dance
The main thing to understand is the FFI boundary. Bun can’t just hand Rust a JavaScript array — it needs to pass raw memory pointers. TypedArray types (Float64Array, Uint8Array, etc.) are backed by a contiguous memory buffer, so Bun’s ptr() function can extract the raw pointer to hand off.
On the Rust side, you reconstruct a slice from that pointer using std::slice::from_raw_parts. This is the unsafe block — you’re asserting that the memory is valid for the duration of the call. Since Bun holds the TypedArray alive while calling into Rust, this is safe in practice.
This is exactly the same mental model as NumPy: a ndarray is a typed memory buffer, and when you call into a C extension, it receives a pointer to that buffer. The Python/JS runtime doesn’t copy the data — it just tells the native code where to look.
FFI types
Bun maps JavaScript types to C-compatible types using FFIType:
| FFIType | C type |
|---|---|
i32 | int32_t |
u64 | uint64_t |
f32 | float |
f64 | double |
ptr | void* |
cstring | char* |
bool | bool |
Rust’s #[no_mangle] pub extern "C" functions must match these types exactly. i32 in Rust maps to FFIType.i32 in Bun, f64 to FFIType.f64, and so on.
Strings across the boundary
Passing strings is where it gets messier. JavaScript strings are UTF-16 internally; C strings are null-terminated UTF-8. Bun provides CString to bridge this:
import { CString, ptr } from "bun:ffi";
// Rust returns a *const c_char
const rawPtr = lib.symbols.get_label();
const label = new CString(rawPtr);
console.log(label); // regular JS string
For the Rust side, returning a string means returning a pointer to a CString you’ve leaked into memory (and remembering to free it later):
use std::ffi::CString;
#[no_mangle]
pub extern "C" fn get_label() -> *const std::os::raw::c_char {
let s = CString::new("hello from rust").unwrap();
s.into_raw() // caller is responsible for freeing this
}
#[no_mangle]
pub extern "C" fn free_label(ptr: *mut std::os::raw::c_char) {
unsafe {
drop(CString::from_raw(ptr));
}
}
For most use cases I avoid strings at the FFI boundary entirely and just pass numeric buffers back and forth.
Memory management
bun:ffi does not manage memory for you. Whatever Rust allocates on the heap, you need to free — either by exposing a free_* function that Bun calls after it’s done, or by using stack-allocated buffers that only live for the duration of the call.
The pattern I use most is to pre-allocate output buffers in JavaScript, pass them as pointers, and let Rust write into them. No allocation on the Rust side, no cleanup needed.
// JS allocates, Rust writes
const output = new Float64Array(input.length);
lib.symbols.process(ptr(input), ptr(output), BigInt(input.length));
// output is populated, no cleanup needed
Performance
According to Bun’s own benchmarks, bun:ffi is roughly 2–6x faster than Node.js FFI via Node-API. The reason is that Bun JIT-compiles C bindings at load time using an embedded TinyCC compiler, so the call overhead is minimal.
For my normalization example on a million floats, the Rust version runs in about 4ms on my machine. The pure JS version takes closer to 80ms. That’s the same order-of-magnitude speedup I was getting with Cython years ago.
When to reach for this
This isn’t a tool for every situation. The FFI call itself has overhead — for tiny inputs, pure JS will win. The setup is also more involved than just writing TypeScript. Reach for bun:ffi when:
- You have a tight loop over a large dataset
- You’re doing compute-heavy work: image processing, cryptography, compression, signal processing
- You’ve already profiled and confirmed the bottleneck is CPU, not I/O
For I/O-bound work, Bun’s async primitives are already fast. FFI is specifically for CPU-bound hotspots — the same niche that Cython occupies in Python land.
The mental model transfer is almost 1:1: keep orchestration in the high-level language, push the hot path into compiled native code, communicate through typed memory buffers. It worked in Python, and it works just as well in Bun.