Bringing High-Performance C++ to the Web

Working with MuJoCo’s physics engine, we needed to expose native C++ APIs to the browser — without relying on a server or reimplementing physics in JavaScript. The approach: WebAssembly bindings using Emscripten and Embind.

We needed to build an idiomatic type-safe interop layer capable of handling raw pointers and complex structs. Our focus was on minimizing the overhead of marshaling data between environments. While negligible for one-off interactions, this overhead becomes a performance death sentence for high-frequency (60Hz+) physics loops where copying large engine states every frame saturates the JavaScript garbage collector.

The WebAssembly Memory Model

To JavaScript, WASM memory is a flat ArrayBuffer governed by the WebAssembly Core Specification. This linear memory instance is divided into segments: null, static (compiled global variables), stack (transient function vars), and heap (persistent data and structures). Because both environments share this unified address space, zero-copy data transfer becomes possible; by avoiding costly data duplication, we unlock the critical speed needed—a capability that each pattern below exploits.

The Anti-Pattern: Raw C-Style Bindings

Before diving into optimized bindings, it helps to look at the baseline: exposing raw C++ functions directly to JavaScript.

extern "C" {
    EMSCRIPTEN_KEEPALIVE
    void processData(const char* str, float* floats, int array_size) {
        std::cout << "String: " << str << std::endl;
        std::cout << "Floats: ";
        for (int i = 0; i < array_size; i++) {
            std::cout << floats[i] << (i == array_size - 1 ? "" : ", ");
        }
        std::cout << std::endl;
    }
}

This will expose an unidiomatic and error-prone API to developers:

// 1. Allocate memory for the array
const floatData = new Float32Array([10.5, 20.5, 30.5]);
const bytesPerElement = 4;
const arrayPtr = Module._malloc(floatData.length * bytesPerElement);
Module.HEAPF32.set(floatData, arrayPtr >> 2);

// 2. Use ccall for the function execution
Module.ccall(
  "processData",
  null,
  ["string", "number", "number"],
  ["Hello World", arrayPtr, floatData.length],
);

// 3. Manually free the array
Module._free(arrayPtr);

This approach is fragile: if JavaScript throws an error before _free is called, the memory is leaked permanently. Instead, we want an API that handles lifetimes automatically:

// No malloc, no ccall, no pointers, no manual encoding
Module.processData("Hello World", new Float32Array([1.1, 2.2, 3.3]));

High-Performance Embind Wrapper Strategies

We rely on three core strategies to deliver these idiomatic APIs, all underpinned by a single concept: the “wrapper.” Wrapping native C/C++ APIs allows us to manipulate the contracts exposed by the bindings while preserving the original API behavior. Additionally, these wrappers serve as proxies to seamlessly handle data types that Embind doesn’t natively support.

1. Wrapped Classes

In complex applications, data is often organized into massive C structs packed with raw pointers and dynamic arrays. At first glance, Embind’s value_object binder seems like a convenient solution. However, mapping large structs this way forces a full serialization and deserialization copy every single time the object crosses the JS/C++ boundary—degrading performance at scale. Embind doesn’t gracefully handle functions that use raw struct pointers as parameters or return types.

To solve this, we build a C++ wrapper class for each struct to encapsulate the underlying raw pointer. This wrapper class is what we actually bind via Embind (class_), safely exposing typed getters and setters to JavaScript. This approach gives us two massive advantages:

  • Zero-Copy Performance: When JavaScript reads a property, it accesses only that specific field dynamically from the memory heap, eliminating unnecessary data copying.
  • Automatic Lifecycle Management: The wrapper class seamlessly manages the underlying raw pointer behind the scenes, keeping your memory with explicit lifetime management that reduces accidental leaks.
Figure 1: Wrapper Classes Bridge

The wrapper must define ownership semantics clearly: whether it owns the native allocation, references externally managed memory, or participates in the lifetime of another object.

2. High-Frequency Data Access: WasmBuffer

Performance-heavy applications like physics engines or real-time graphics pipelines frequently move massive data arrays back and forth between environments. Copying these arrays between the WASM heap and the JavaScript heap on every single frame will cause significant frame rate degradation.

To solve this, we introduce the WasmBuffer—a custom C++ class designed to allocate memory directly on the WASM heap. By leveraging Embind’s typed_memory_view, we instruct the binding layer not to copy the data. Instead, it provides JavaScript with a standard TypedArray view (like a Float64Array) pointing directly to that raw memory space.

template<typename T>
class WasmBuffer {
public:
    WasmBuffer(size_t size) : data_(size) {}

    emscripten::val view() {
        return emscripten::val(
            emscripten::typed_memory_view(data_.size(), data_.data()
            )
        );
    }

    T* data() { return data_.data(); }

private:
    std::vector<T> data_;
};

EMSCRIPTEN_BINDINGS(module) {
    emscripten::class_<WasmBuffer<float>>("FloatBuffer").constructor<size_t>()
        .function("getView", &WasmBuffer<float>::view);
}

This creates a shared, zero-copy “window”; both JavaScript and C++ access the same region of WebAssembly linear memory through different views, avoiding serialization and heap-to-heap copies.

Solving the “Out Parameter” problem: This pattern also solves a major Embind limitation regarding “out parameters” (functions that modify an array passed into them). By default, passing a JavaScript array into a C++ function copies the data by value. If C++ mutates that data, those changes are lost to JavaScript.

With WasmBuffer, both sides are looking at the exact same memory address; when C++ writes a new state into the buffer, the fresh values are instantly accessible to JavaScript—no extra function calls or data synchronization required.

Figure 2: WASM Buffer

3. Wrapper Functions

Embind cannot natively handle C APIs using raw pointers (float*, char*, someStruct*) without forcing JS developers into error-prone manual memory management. To bridge this, wrapper functions abstract pointer complexity through three strategies:

  • Struct Pointers: The wrapper accepts a C++ wrapper class reference instead of a raw pointer, extracting the internal address for the native call.
void step(mjModel* m, double dt); // Before
void step_wrapper(MjModelWrapper& model, double dt); // After
  • Out Parameters: Replaces raw “output” pointers with a WasmBuffer. The engine writes directly to WASM memory, enabling zero-copy access via JS typed arrays.
void getState(mjModel* m, double* qpos); // Before
void getState_wrapper(MjModelWrapper& model, WasmBuffer<double>& qpos); // After
  • Unsupported Types: Converts types like char* or primitive arrays to safe C++ types (e.g., std::string) to handle boundary crossing without memory leaks.
const char* sanitizeName(char* name); // Before
std::string sanitizeName_wrapper(std::string& name); // After

This approach provides an idiomatic JavaScript experience while keeping the core native engine intact.

Conclusion

By combining wrapped classes, WasmBuffer, and wrapper functions, you get a type-safe API that feels natural in JavaScript while running at native C++ performance.

🔗 Explore the technical lab materials and follow them at your own pace.

Contact us if you want support designing or implementing a high performance bridge for your C/C++ app to the Web.

The goal is not to hide WebAssembly—it is to design a boundary where each side operates using the abstractions it is best suited for.