Welcome to part 3 of our series, Porting Native Apps to the Web with WebAssembly: A Complete Guide.
Not up to date? Read part 2.
In previous articles, we explored how native applications can be compiled to WebAssembly (Wasm) and run in the browser. We also discussed the advantages of this approach compared to rewriting applications from scratch.
In nearly all cases, however, the process isn’t as simple as compiling the source code. Most applications require porting to WebAssembly before they can function properly in a browser environment. This article explains what porting means, why it’s necessary, and what it involves.
A key concept to understand is that typical applications don’t operate in isolation. While source code defines an application’s logic, it relies on various system services to perform meaningful work. For example, an application may need to:
All of these tasks are fulfilled by the operating system, which provides essential services through system calls (syscalls). These are low-level functions that allow programs to request resources, interact with hardware, and communicate with other processes.
Native applications access system services provided by the operating system through syscalls.
Typically, programming languages and libraries provide abstractions over these low level APIs that you call from your program. The OS is then responsible for executing the requested actions and returning a result that is safe to handle within the application.
However, when compiling an application to WebAssembly, there is a major missing link: Wasm runs in a strictly sandboxed environment that lacks direct access to system calls or operating system services. Instead, it can only interact with the outside world through functions explicitly imported from the host environment — in the case of the browser, this means calling JavaScript APIs.
Since WebAssembly does not support traditional system calls, porting an application means replacing these system interactions with equivalents that work in a browser environment. This is conceptually similar to porting software from one operating system to another — such as adapting a Linux application to run on Windows — but with additional constraints due to different “building blocks” (primitives and APIs) in the browser environment.
Wasm modules can only access system services through JavaScript browser APIs.
So at a high level, porting an application to the browser involves adapting code that interacts with the underlying system to instead use JavaScript-based alternatives. For example:
fetch
for networking instead of native sockets.For certain features, there is a good matching browser API. For example, playing simple audio or drawing basic 2D graphics can be implemented in a conceptually similar way on the browser as in a native application (even though the details of the APIs themselves are different).
However, some features have no conceptual parallel on the browser, and must be
emulated or re-implemented. One such example is the fork
syscall, which
duplicates a process into two identical copies; this is a syscall available on
native systems that cannot have a faithful implementation on the browser.1
While system calls are a major part of the challenge, porting is often further complicated by dependencies on libraries, frameworks, or language runtimes.
Many programming languages include a runtime or virtual machine that manages execution of your code, including memory management and system interactions. Examples include:
These runtime components assume access to system services that are not available in a Wasm sandbox. Porting an application written in such a language may require adapting or porting the runtime itself, or replacing it with a Wasm-compatible alternative. Fortunately, a lot of tooling and wasm-compatible runtimes2 exist, and this burden is rarely borne directly by application developers.
Applications often rely on frameworks and libraries that provide essential functionality but are deeply integrated with native platforms. Porting such applications requires addressing these dependencies. For example:
In these cases, porting requires one or more of the following approaches:
We’ll use rendering a single pixel as an example; the code is simple, but it demonstrates the key differences between how native applications and WebAssembly interact with their respective environments.
Let’s consider the simple C++ function below that uses the Win32 SetPixel
function to draw to a global window:
void drawPixel(int x, int y, int color) {
SetPixel(globalWindowHandle, x, y, color);
}
When this function is compiled and run natively on Windows:
SetPixel
function is part of the Win32 API, which interacts with the operating system.If the same C++ function were compiled to WebAssembly, it would not work as-is, because WebAssembly cannot directly access the Win32 API or the OS graphics subsystem. Instead, it must be ported to the web, i.e., rewritten to call JavaScript, which interacts with the browser’s rendering engine.
We can introduce a JavaScript function, in this case drawing to a HTML canvas element:
const canvas = document.getElementById("myCanvas");
const ctx = canvas.getContext("2d");
function SetPixel(x, y, color) {
ctx.fillStyle = intToRgb(color); // Set the color of the pixel
ctx.fillRect(x, y, 1, 1); // Draws it at (x, y)
}
Note: the implementation of intToRgb
is omitted here for brevity.
This function can then be imported into the Wasm module and used as the low-level API for rendering the pixel.
The modified C++ code might look as follows:
// The code within the block below is only compiled for Wasm
#ifdef __WASM__
namespace [[cheerp::genericjs]] client {
// Declare that there’s an imported JS function for drawing a pixel
void SetPixel(int x, int y, int color);
}
#endif
void drawPixel(int x, int y, int color) {
#ifdef __WASM__
// In Wasm build: Use the JavaScript function
client::SetPixel(x, y, color);
#else
// In native build: Use the normal Win32 function
SetPixel(globalWindowHandle, x, y, color);
#endif
}
Note: the example above uses the semantics of the Cheerp C++ to Wasm compiler, but similar code is possible using Emscripten.
In this WebAssembly-friendly approach:
This example illustrates why porting is necessary — code that interacts with the operating system must be adapted to work within the browser’s execution model.
In practice, compilers and tools can automatically transform many common patterns (e.g. memory allocation, some standard library functions) into browser-compatible equivalents. This means that porting efforts are typically required only for application specific functionality where such transformations do not apply.
For example, in standard C++ to Wasm compilers (e.g. Cheerp, Emscripten),
writing to stdout
is automatically redirected to print in the browser console,
requiring no manual code changes. Similarly, memory allocation, and growing the
size of the WebAssembly memory is handled automatically by the compiler. While
these transformations happen behind the scenes, they follow the same conceptual
process as illustrated above; that is, they automatically replace calls to
native APIs with calls to JavaScript APIs.
Porting an application to WebAssembly is often more than just recompilation — it involves adapting system interactions, dependencies, and runtime expectations to function in a browser sandbox. This can mean:
This process can be partially automated by modern tooling and compilers, which greatly simplifies the process of porting.
In future articles, we will explore the differences between the browser and native platforms in more detail, discussing common challenges and practical techniques for overcoming them.
Stay tuned for part 4, or review part 2: When should you use WebAssembly?
See this issue for some discussion about why fork
cannot faithfully be
implemented in the browser. ↩
Several examples of wasm-compatible language runtimes have been touched on in previous articles. ↩
On this page