Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Software Engineering

Undefined Behavior Risks in Rust and JavaScript Cross Compilation

Stop rust undefined behavior cross compiling to JavaScript runtimes. Fix memory safety bugs, secure WASM boundaries, prevent runtime crashes.

Dian Rijal Asyrof/August 22, 2026/5 min read
Illustration for Undefined Behavior Risks in Rust and JavaScript Cross Compilation

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.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleRamp Releases Unified API Router for Dynamic LLM SwitchingNext articleMalware Attack Targets Security Researchers via Fake Crypto Conference
RustJavaScriptWebassemblyCompilationDeserialization
On this page↓
  1. The Shared Memory Illusion in WebAssembly
  2. Lifetime Erasure at the Boundary
  3. Node-API and the Threading Trap
  4. Panic Handling Across the FFI Boundary
  5. Struct Alignment and Memory Layout Discrepancies
  6. Defensive Engineering for Cross-Border Code

On this page

  1. The Shared Memory Illusion in WebAssembly
  2. Lifetime Erasure at the Boundary
  3. Node-API and the Threading Trap
  4. Panic Handling Across the FFI Boundary
  5. Struct Alignment and Memory Layout Discrepancies
  6. Defensive Engineering for Cross-Border Code

See also

Illustration for Rust Async Processing with Zero-Copy Deserialization
Software Engineering/Aug 19, 2026

Rust Async Processing with Zero-Copy Deserialization

Maximize performance in your network applications with rust zero copy async techniques. Learn to parse data streams efficiently without extra memory allocations.

8 min read
RustDeserialization
Illustration for Malicious Rust Crate Arrayref Executes Arbitrary Build-Time Payloads
Programming/Aug 22, 2026

Malicious Rust Crate Arrayref Executes Arbitrary Build-Time Payloads

Detect rust arrayref malware crate executing remote code via proc-macro build scripts. Secure cargo supply chain against malicious dependency injection.

6 min read
RustArrayref
Illustration for Loupe Offers Real-Time In-App Debugging Overlay for React Native
Web Development/Aug 22, 2026

Loupe Offers Real-Time In-App Debugging Overlay for React Native

Embed a real-time react native debug overlay directly in production builds. Monitor network requests and console logs on device without external tools.

6 min read
LoupeReact Native