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. NumPy plays the same trick: the Python layer is a thin wrapper, and the expensive work happens in compiled native code. The speedup is often 10 to 100x.
I’ve been using Bun as my daily runtime for TypeScript, and I ran into the same kind of situation: a hot loop where JavaScript just wasn’t fast enough. Bun has a built-in answer for this: bun:ffi.
The idea is the same as the Python/Cython pattern. Keep your application logic in TypeScript and hand the CPU-intensive parts to a compiled Rust library. The boundary between them is the C ABI, the common calling convention 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 it gives you memory safety without a garbage collector, so you aren’t introducing segfault risk. cargo makes building shared libraries straightforward, and 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));
}
That’s fine for small arrays and painful at scale. Here’s how to push it 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 hand Rust a JavaScript array; it has 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 pass along.
On the Rust side, you reconstruct a slice from that pointer using std::slice::from_raw_parts. That’s 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.
It’s the same mental model as NumPy. An ndarray is a typed memory buffer, and a C extension receives a pointer to that buffer. The Python or JS runtime doesn’t copy the data; it 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 have to free, either by exposing a free_* function that Bun calls when 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. Rust allocates nothing, so there’s nothing to clean up.
// 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 to 6x faster than Node.js FFI via Node-API, because 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 used to get with Cython.
When to reach for this
The FFI call itself has overhead, so for tiny inputs pure JS wins, and the setup is 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 such as image processing, cryptography, compression, or signal processing
- You’ve profiled and confirmed the bottleneck is CPU and not I/O
For I/O-bound work, Bun’s async primitives are already fast. FFI is for CPU-bound hotspots, the same niche Cython fills in Python.
The mental model carries over almost exactly: keep orchestration in the high-level language, push the hot path into compiled native code, and communicate through typed memory buffers. It worked for me in Python, and it works just as well in Bun.