Writing Rust code to run in JavaScript environments has become standard practice. We do it for speed, using WebAssembly in the browser, or native Node-API addons on the server. Both Rust and JavaScript advertise safety. Rust guarantees memory safety at compile time. JavaScript isolates execution in a managed virtual machine with garbage collection.
But compiling Rust to target JavaScript runtimes creates a dangerous boundary. The safety models of the two languages do not merge. Instead, they expose a gap where undefined behavior can occur without compiler warnings. When you pass data across this boundary, you bypass the safety checks of both systems.
The Shared Memory Illusion in WebAssembly
WebAssembly (Wasm) runtimes run in a sandboxed environment. This sandbox prevents Wasm code from corrupting the host machine's memory. However, it does not prevent Wasm code from corrupting its own memory.
In Wasm, the heap is represented as a single, contiguous raw byte array called WebAssembly.Memory. When Rust code allocates memory, it happens inside this array. When you pass a reference, like a string slice or a vector, from Rust to JavaScript, you are passing an offset and a length inside this memory buffer.
JavaScript code accesses this memory using typed arrays, like Uint8Array. The danger arises because the JavaScript runtime does not track the lifetime of the Rust allocation.
Consider this pattern:
#[wasm_bindgen]
pub struct DataBuffer {
data: Vec<u8>,
}
#[wasm_bindgen]
impl DataBuffer {
pub fn new() -> Self {
Self { data: vec![0; 1024] }
}
pub fn get_ptr(&self) -> *const u8 {
self.data.as_ptr()
}
pub fn grow(&mut self) {
self.data.reserve(2048);
}
}In JavaScript, a developer might instantiate this buffer and create a view:
const buffer = DataBuffer.new();
const ptr = buffer.get_ptr();
const view = new Uint8Array(wasmMemory.buffer, ptr, 1024);
// Modify buffer size
buffer.grow();
// Write to the old view
view[0] = 42;When buffer.grow() is called, Rust may reallocate the vector to a new memory address to accommodate the larger size. The original memory location is freed. The JavaScript view still points to the old memory address.
Writing to view[0] now writes directly into freed memory. This is a classic use-after-free bug. Because this happens inside the Wasm linear memory, the operating system will not trigger a segmentation fault. The program continues running, but the Rust allocator's metadata or other Rust objects occupying that freed space will be corrupted.
Lifetime Erasure at the Boundary
Rust uses ownership and the borrow checker to ensure references do not outlive their owners. The compiler rejects code that violates this. But when compiling to WebAssembly or native Node.js addons, lifetimes are erased at the boundary. JavaScript has no concept of Rust lifetimes.
When you pass a Rust struct to JavaScript, tools like wasm-bindgen wrap the pointer in a JavaScript object wrapper. This wrapper contains a pointer to the Rust struct in Wasm memory. To prevent memory leaks, you must manually free this memory when you are done with it. The generated JavaScript code provides a free() method for this purpose.
If JavaScript code calls a method on the wrapper after calling free(), the wrapper will attempt to access a null pointer, which wasm-bindgen checks and throws a JavaScript error for. But if you extract the pointer manually or bypass the wrapper, you can easily cause a double-free.
For example:
#[no_mangle]
pub extern "C" fn free_buffer(ptr: *mut u8, len: usize) {
unsafe {
let _ = Vec::from_raw_parts(ptr, len, len);
}
}If the JavaScript side calls free_buffer twice with the same pointer, the allocator will free the same memory block twice. This corrupts the heap structure, leading to unpredictable crashes when Rust allocates memory later.
Node-API and the Threading Trap
When writing native Node.js addons using Node-API (via libraries like napi-rs or Neon), the boundary is even more fragile. Unlike WebAssembly, native addons run outside a sandbox. Undefined behavior here can crash the entire Node.js process or open security vulnerabilities on the host system.
The most common source of bugs in native addons is threading. Rust enforces thread safety via the Send and Sync traits. If a type does not implement Send, you cannot transfer it to another thread.
However, Node.js runs the V8 engine on a single main thread (the event loop thread). V8 objects, such as napi_value or raw pointers to JavaScript objects, are bound to this thread. They are not thread-safe.
If you write a Rust function that spawns a background thread to offload heavy calculations, you cannot pass a raw JavaScript callback or object to that background thread.
// Dangerous pattern in napi-rs
#[napi]
pub fn run_async(callback: JsFunction) {
std::thread::spawn(move || {
// Calling callback directly from a background thread
// will violate V8 internal invariants and crash.
callback.call(None, &[]).unwrap();
});
}The V8 engine assumes that only the thread owning the context will access V8 objects. When the background thread attempts to invoke the callback, it accesses V8 memory without acquiring the engine's internal locks. This leads to an immediate memory access violation, usually resulting in a segmentation fault.
To avoid this, you must use thread-safe functions provided by the Node-API wrapper library. These wrappers queue the callback execution on the main Node.js event loop thread, ensuring that V8 is only accessed from the correct thread.
Panic Handling Across the FFI Boundary
Rust handles unexpected errors by panicking. By default, a panic unwinds the stack, running destructors for all active objects in reverse order.
However, the foreign function interface (FFI) boundary cannot handle Rust unwinding. If a Rust function called by JavaScript panics, and the panic is allowed to unwind across the ABI boundary into the JavaScript engine, the behavior is undefined.
The JavaScript runtime stack does not have the metadata required to handle Rust stack unwinding. This mismatch can corrupt the stack pointer, skip destructors of active Rust objects, or crash the process.
To prevent this, any Rust code exposed to JavaScript must catch panics at the boundary.
use std::panic::catch_unwind;
#[no_mangle]
pub extern "C" fn calculate_safe(input: i32) -> i32 {
let result = catch_unwind(|| {
if input < 0 {
panic!("Input must be positive");
}
input * 2
});
match result {
Ok(val) => val,
Err(_) => -1,
}
}Configuring your project to abort on panic is a safer default when compiling for cross-language targets. Adding panic = "abort" to your Cargo.toml ensures the process terminates immediately when a panic occurs, preventing stack corruption.
Struct Alignment and Memory Layout Discrepancies
When sharing data structures between Rust and JavaScript via raw memory buffers, alignment differences can cause undefined behavior.
Rust structures do not have a guaranteed memory layout by default. The compiler can reorder fields to minimize padding. If you cast a raw pointer from a JavaScript ArrayBuffer directly into a Rust struct reference, you assume the layouts match.
#[repr(Rust)]
struct Payload {
id: u32,
flag: u8,
value: u64,
}If the JavaScript code writes fields to a buffer assuming a specific C-like layout, and Rust reads it using the default representation, fields will align incorrectly. This results in reading garbage data.
Furthermore, reading unaligned data can cause hardware-level exceptions on architectures like ARM. Rust references must always be aligned to their type's alignment requirements. Creating a reference to an unaligned memory address inside a shared buffer is instant undefined behavior in Rust, allowing the compiler to optimize away checks or generate invalid machine instructions.
You must use #[repr(C)] to force a predictable C-compatible layout, and use bytemuck or similar libraries to safely cast bytes without violating alignment rules.
Defensive Engineering for Cross-Border Code
To build safe cross-compiled applications, you must design your interfaces defensively.
First, minimize shared mutable state. Instead of passing pointers to internal Rust data structures to JavaScript, copy the data across the boundary when performance allows. Serializing data to JSON or using zero-copy deserialization reduces the surface area for memory safety issues.
Second, enforce panic safety. Always configure release profiles to abort on panic when targeting WebAssembly or Node-API.
Third, use automated wrappers but verify their output. Tools like wasm-bindgen and napi-rs generate JavaScript glue code that handles memory management, but they cannot prevent logic errors like calling methods on freed objects if you bypass their generated APIs.
Finally, run tests with memory sanitizers. When building native Node.js addons, compile your Rust code with AddressSanitizer (ASan) to catch use-after-free and buffer overflow bugs during local development and CI runs.



