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

  • Memory safety without a garbage collector — you’re not introducing segfault risk
  • cargo makes 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:

FFITypeC type
i32int32_t
u64uint64_t
f32float
f64double
ptrvoid*
cstringchar*
boolbool

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.