Supercharging Bun with Rust via FFI

The same way Python offloads heavy computation to C extensions via NumPy or Cython, you can drop native Rust into your Bun app using bun:ffi — with near-zero overhead.

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:ffi is 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.