So I've been working on a project for a while to create a realtime, high performance JavaScript Chart Library. This project uses quite an ambitious & novel tech stack including a large legacy codebase in C/C++ which is compiled to WebAssembly using Emscripten, targetting WebGL, and a TypeScript API wrapper allowing you to load the charts in JS without having to worry about the underlying Wasm.
First up, why use Wasm at all?
WebAssembly is an exciting technology and offers performance benefits over JavaScript in many cases. Also, in this case a legacy C++ codebase already handled much of the rendering for charts & graphs in OpenGL, and needed only a little work to be able to target WebGL.
It's fairly easy to compile existing C++ code into WebAssembly using Emscripten and all that remains is writing bindings to generate Typings and then your JavaScript API around the Wasm library to use it.
During the development of the library we learned some interesting things about the WebAssembly memory model, how to avoid and debug memory leaks which I'll share below.
JavaScript vs. WebAssembly Memory Model
WebAssembly has a completely different memory model to JavaScript. While JavaScript has a garbage collector, which automatically cleans up the memory of variables which are no longer required, WebAssembly simply does not. An object or buffer declared in Wasm memory must be deleted by the caller, if not a memory leak will occur.
How Memory Leaks are caused in JavaScript
Memory leaks can occur in both JavaScript and WebAssembly, and care and attention must be taken by the developer to ensure that memory is correctly cleaned up when using WebAssembly.
Despite being a Garbage-Collected managed programming language, it’s still extremely easy to create a memory leak just in vanilla JavaScript. Here are a couple of ways that is possible to inadvertently leak memory in a JavaScript app:
- Arrow functions and closure can capture variables and keep them alive, so they cannot be deleted by the JavaScript garbage collector
- Callbacks or event listeners can capture a variable and keep it alive.
- Global variables or static variables to stay alive for the lifetime of the application. Simply forgetting to use let or const can convert a variable to a global variable.
- Even detached DOM nodes can keep objects alive in JavaScript. Simply removing a node from the DOM but keeping a variable to it can prevent the node and it’s children from being collected.
How Memory Leaks are caused in WebAssembly
Wasm has a separate heap to the JavaScript virtual machine. This memory is allocated in the browser, and reserved from the host OS. When you allocate memory in Wasm, the Wasm heap is grown and a range of addresses are reserved. When you delete memory in Wasm, the heap does not shrink and memory is not returned to the host OS. Instead the memory is simply marked as deleted or available. This means it can be re-used by future allocations.
To cause a memory leak in WebAssembly you simply need to allocate memory and forget to delete it. Since there is no automatic garbage collection, finalisation or marking memory as no longer needed, it must come from the user. All WebAssembly types exported by the compiler Emscripten have a function .delete() on objects that use Wasm memory. This needs to be called when the object is no longer required. Here's a quick example:
Example: Leaking Memory in Wasm
Assuming you have a type declared in C++ like this:
// person.cpp
#include <string>
class Person {
public:
// C++ Constructor
Person(std::string name, int age) : name(name), age(age)
{}
// C++ Destructor
~Person() {}
std::string getName() { return name; }
int getAge() { return age; }
private:
std::string name;
int age;
};
and compile and export the type using Emscripten like this
emcc person.cpp -o person.js -s EXPORTED_FUNCTIONS="['_createPerson', '_deletePerson', '_getName', '_getAge']" -s MODULARIZE=1
you can now instantiate, use and delete the type in JavaScript like this:
const Module = require('./person.js'); // Include the generated JavaScript interface
Module.onRuntimeInitialized = () => {
// Instantiate a Person object
const person = new Module.Person('John Doe', 30);
console.log('Person object created:', person);
// Access and print properties
console.log('Name:', person.getName());
console.log('Age:', person.getAge());
// Delete the Person object (calls the C++ destructor)
person.delete();
};
Forgetting to call person.delete()
however causes a Wasm memory leak. *The memory in the browser will grow and not shrink. *
However, what I also learned was even if you do call .delete(), memory can appear to grow.
Here's why:
Detecting Memory Leaks in WebAssembly applications
Because a memory leak is catastrophic to an application, we had to ensure that our code did not leak memory, but also the user code (those consuming and using our JavaScript Chart Library in their own applications) also did not leak memory.
To solve this we developed our own in-house memory debugging tools. This is implemented as an object registry which is a Map<string, TObjectEntryInfo>
of all objects undeleted and uncollected where TObjectEntryInfo
is a type which stores WeakRef
to the object.
Using a JavaScript proxy technique we were able to intercept calls to new/delete on all WebAssembly types. Each time an object was instantiated, we added it into the objectRegistry
and each time it was deleted, we removed it from the objectRegistry
.
Now you can run your application, enable the memory debugging tools and output specific snapshots of your application state. Here's an example of what the tools output.
First enable the MemoryUsageHelper
(memory debugging tools)
import { MemoryUsageHelper} from "scichart";
MemoryUsageHelper.isMemoryUsageDebugEnabled = true;
This automatically tracks all the types in our own library, but you can track any arbitrary object in your application by calling register
and unregister
like this:
// Register an arbitrary object
MemoryUsageHelper.register(yourObject, "identifier");
// Unregister an arbitrary object
MemoryUsageHelper.unregister("identifier");
Later, at a specific point output a snapshot by calling this function:
MemoryUsageHelper.objectRegistry.log();
This outputs to the console all the objects which have not been deleted, or uncollected
How to use this output
Objects that are in the undeletedObjectsMap may either be still alive, or perhaps you've forgotten to delete them. In which case the resolution is simple. Call .delete() on the object when you are done with it
Objects in uncollectedObjectsMap have not yet been garbage collected. This could be a traditional JS memory leak (which also affects Wasm memory) so check for global variables, closure, callbacks and other causes of traditional JS memory leaks
objects in deletedNotCollected and collectedNotDeleted identify possible leaks where an object was collected by the javascript garbage collector but not deleted (and vice versa)
MemoryUsageHelper
Wasm Memory leak debugging tools are part of SciChart.js, available on npm with a free community license at npmjs.com/package/scichart.It can be used in WebAssembly applications or JavaScript applications to track memory usage.
Further reading can be seen at MemoryLeakDebugging and Debugging WebAssembly Memory Leaks
Top comments (0)