How to run C++ applications as Webassembly with Javascript

Everything you need to know to build and deploy c++ code as Webassembly on the Web. A step by step tutorial for absolute beginners to get started with Webassembly and its ecosystem. Learn how to compile c++ code to wasm, use it in your javascript projects, and deploy it on the web.

Posted On: Monday, 09-Feb-2026
How to run C++ applications as Webassembly with Javascript

If you've ever wondered how Figma renders complex vector graphics at buttery smooth 60fps, or how Google Earth renders 3D terrain right in your browser -- the secret sauce is WebAssembly (WASM). And in 2026, it's not just for the big tech giants anymore. WASM has quietly become one of the most powerful tools in a Javascript developer's arsenal, sitting right beside your React components and doing the heavy lifting that JS was never designed to do.

Here's the thing -- Javascript runs on a single thread. That one thread is already juggling user clicks, DOM updates, network calls, and that one animation you promised the designer would be "smooth". Asking it to also crunch through a million iterations of a loop? That's like asking your barista to also fix your car while making your latte. Not ideal.

That's where WebAssembly comes in. It runs compute-heavy tasks at near-native speed, right inside the browser, without blocking your UI. And the best part? You can write it in C++, Rust, Go -- whatever you're comfortable with -- and call it from Javascript as if it were just another function.

In this tutorial, I'll walk you through the entire journey -- from writing a simple C++ program, compiling it to WebAssembly, and loading it in a React component. Baby steps, no shortcuts, and by the end, you'll have a working demo that'll make you rethink what's possible on the web.

Prerequisites

Before we dive in, let's set expectations. You don't need to be a C++ wizard. If you can read a for loop and understand what #include does, you're golden. Here's what you'll need:

  1. C++ basics -- able to read and understand basic C++ syntax and compilation steps (no template metaprogramming, I promise)
  2. Javascript -- advanced level. You should be comfortable with async/await, dynamic imports, and ES6 modules

This tutorial is built for JS developers who are curious about WASM and want to go beyond the "hello world" blog posts. We'll get our hands dirty with real compilation, real debugging, and a real performance comparison at the end.

Chapter 1: A simple C++ program

Every good story starts with something simple. Ours starts with a C++ function that's intentionally dumb -- it calculates the sum from 0 to N using a loop. Yes, I know there's a formula for that. But the point here isn't mathematical elegance -- it's to simulate a CPU-intensive task, the kind of work that would make your browser's UI thread cry if you ran it in Javascript.

cppLooper.cpp
#include <iostream>
#include <cstdlib>
#include <cstdint>

std::uint64_t runLoop(std::uint64_t N)
{
    std::uint64_t sum = 0;
    for (std::uint64_t i = 0; i <= N; ++i)
    {
        /* Some fake task, just to demonstrate
         * a CPU intensive work. */
        sum += i;  
    }
    return sum;
}

int main(int argc, char **argv)
{

    char *end = nullptr;
    const std::uint64_t N = std::strtoull(argv[1], &end, 10);
    std::cout << runLoop(N) << std::endl;
    return 0;
}

Let me break this down:

  • runLoop is our workhorse -- it takes an integer N and sums up every number from 0 to N. E.g.
    Input: 5
    Output: 15
    Explanation: 0 + 1 + 2 + 3 + 4 + 5 = 15

  • main is the entry point of our program -- it grabs N from the command line, hands it to runLoop, and prints the result. Simple as it gets.

Compile and test the program

I'll be using Ubuntu on WSL2 (Windows Subsystem for Linux) for this tutorial -- it's my daily driver for anything that needs a proper Linux environment without leaving Windows. But if you're on macOS or a native Linux distro, you're good to go. The commands are the same.

We'll need g++ to compile our C++ code. If you don't have a C++ compiler set up yet, no worries -- here's how to get it done in 30 seconds:

# updates the distribution database on your system
$ sudo apt update
# upgrades to latest versions of packages installed on your system
$ sudo apt upgrade 

Next we will install the build-essential which will c++ compiler (g++) along with its dependencies. Learn more about build-essential

# install c++ compiler
$ sudo apt install build-essential

Now, lets test if we actually have g++ installed.

# Check if c++ compiler is installed by checking the version of g++
$ g++ --version
# g++ (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0
# Copyright (C) 2023 Free Software Foundation, Inc.
# This is free software; see the source for copying conditions.  There is NO
# warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

Once g++ is ready, let's compile our beautifully dumb C++ code and take it for a spin:

# Compile cppLooper.cpp code and create cppLooper executable.
$ g++ -std=c++17 -O2 -DNDEBUG ./cppLooper.cpp -o cppLooper

A quick breakdown of those flags, because I believe in understanding why we type what we type:

  • -std=c++17: Tells the compiler we're writing modern C++ (v17). This unlocks features like std::optional, structured bindings, and other goodies -- though we don't need them here, it's a good habit.
  • -O2: Optimization level 2. This is the sweet spot for production builds -- the compiler aggressively optimizes without getting into the "takes forever to compile" territory of -O3.
  • -DNDEBUG: Strips out assert() calls and debug code. A small but meaningful optimization that reduces binary size and avoids unnecessary condition checks.
  • -o cppLooper: Names our output binary cppLooper instead of the default a.out. Because a.out tells you nothing about what it does.

Moment of truth -- let's run this thing:

# Adds permissions to execute cppLooper binary
$ chmod +x ./cppLooper
$ ls -lrt
# -rw-r--r-- 1 nayaabh devyin   468 Feb 17 23:12 cppLooper.cpp
# -rwxr-xr-x 1 nayaabh devyin 16520 Feb 17 23:40 cppLooper
$ ./cppLooper 10
55
$ ./cppLooper 5
15
$ ./cppLooper 909090
413222768595

🎉 It works! Our little looper is doing exactly what it should. But hold on -- it only runs on your machine right now. The big question is: how do we get this running inside a browser?

Before we answer that, let me take you on a quick detour. Think of it as a detective investigation 🕵️ -- we need to understand what's inside the binary we just built. Specifically, what functions (or "symbols" in compiler-speak) are exposed and callable. The nm command is our magnifying glass:

$ nm --extern-only --defined-only ./cppLooper
0000000000002000 R _IO_stdin_used
0000000000001320 T _Z7runLoopm  # <-- O_- hmm.. This looks familiar. something something runLoop something..
0000000000001370 W _ZNKSt5ctypeIcE8do_widenEc
0000000000004040 B _ZSt4cout@GLIBCXX_3.4
0000000000004010 D __TMC_END__
0000000000004040 B __bss_start
0000000000004000 D __data_start
0000000000004008 D __dso_handle
0000000000004010 D _edata
0000000000004158 B _end
0000000000001378 T _fini
0000000000001000 T _init
0000000000001230 T _start
0000000000004000 W data_start
0000000000001120 T main         # <-- oh! Oh! i know this

Interesting! We can spot main in there -- no surprises, that's expected; without it exposed, you couldn't run the binary at all. But wait... what's _Z7runLoopm? That looks like our runLoop function, but it's been mangled -- the compiler added _Z7 at the beginning and m at the end.

This is called C++ name mangling, and it's actually the compiler being helpful (in its own weird way). C++ supports function overloading -- you can have multiple functions with the same name but different parameters. So the compiler encodes the parameter types into the symbol name to tell them apart. The m at the end? That's the mangled representation of unsigned long.

Cool trivia, but terrible news for us. When we compile to WebAssembly, we need to know the exact symbol name to call our function from Javascript. And these mangled names are unpredictable -- they change based on compiler, platform, and parameter types. We need a way to tell the compiler: "Hey, keep the name exactly as I wrote it."

Chapter 2: Exposing C++ functions

And that's exactly what extern "C" does. It tells the C++ compiler: "Treat these functions like plain C functions -- no name mangling, no funny business." C doesn't have function overloading, so there's no need to encode parameter types into the name. What you write is what you get.

Let's wrap our runLoop function in an extern "C" block:

cppLooper.cpp
#include <iostream>
#include <cstdlib>
#include <cstdint>

extern "C" // 👈🏼 Exposes symbols as-is
{
    std::uint64_t runLoop(std::uint64_t N)
    {
        std::uint64_t sum = 0;
        for (std::uint64_t i = 0; i <= N; ++i)
        {
            // Some fake work, just to demonstrate a CPU intensive work.
            sum += i;
        }
        return sum;
    }
}

int main(int argc, char **argv)
{

    char *end = nullptr;
    const std::uint64_t N = std::strtoull(argv[1], &end, 10);
    std::cout << runLoop(N) << std::endl;
    return 0;
}

Notice we're not wrapping main -- it's already exposed, and besides, it uses stdin/stdout which don't exist in a browser context. No console to type into, no terminal to print to. (Side note: if you're building WASM for Node.js to run on the server, standard I/O is totally fair game. But that's a story for another blog post -- one I'm already drafting! 📝)

Let's recompile and check our symbols:

# Compile with exposed functions
$ g++ -std=c++17 -O2 -DNDEBUG ./cppLooper.cpp -o cppLooper
$ nm --extern-only --defined-only ./cppLooper
0000000000002000 R _IO_stdin_used
0000000000001370 W _ZNKSt5ctypeIcE8do_widenEc
0000000000004040 B _ZSt4cout@GLIBCXX_3.4
0000000000004010 D __TMC_END__
0000000000004040 B __bss_start
0000000000004000 D __data_start
0000000000004008 D __dso_handle
0000000000004010 D _edata
0000000000004158 B _end
0000000000001378 T _fini
0000000000001000 T _init
0000000000001230 T _start
0000000000004000 W data_start
0000000000001120 T main
0000000000001320 T runLoop      # Nice! just the way we defined it.

Beautiful. runLoop is now exposed with its clean, unmutilated name. No more cryptic _Z7runLoopm.

Let's take a breath and appreciate how far we've come:

  1. ✅ We can compile C++ code with optimized flags
  2. ✅ We can inspect binary symbols and understand name mangling
  3. ✅ We can expose clean function names using extern "C"

Now comes the fun part -- getting this to run in a browser.

Chapter 3: Emscripten -- our gateway to WebAssembly

So far, g++ has been great for understanding the compilation process. But g++ compiles to native machine code -- x86, ARM, whatever your CPU speaks. We need something that compiles to WebAssembly, a portable bytecode format that browsers understand natively.

Enter Emscripten -- the Swiss Army knife of the WASM world. It's been around since 2012 (before WASM even existed -- it originally compiled to asm.js!) and is by far the most mature C/C++ to WASM toolchain. There are alternatives like wasi-sdk if you're targeting the WASI runtime, but for browser-targeted WASM, Emscripten is the gold standard.

Emscripten does two critical things for us:

  1. Compiles C++ to WebAssembly -- the .wasm binary that browsers can execute
  2. Generates Javascript glue code -- a .js file that handles loading the WASM module, managing memory, and providing helper functions to call our C++ functions from JS

Let's get it installed. Fair warning -- it downloads a few hundred MB of toolchain binaries, so grab a coffee ☕:

$ git clone https://github.com/emscripten-core/emsdk.git
# Cloning into 'emsdk'...
# remote: Enumerating objects: 4805, done.
# remote: Counting objects: 100% (8/8), done.
# remote: Compressing objects: 100% (5/5), done.
# remote: Total 4805 (delta 4), reused 3 (delta 3), pack-reused 4797 (from 2)
# Receiving objects: 100% (4805/4805), 2.65 MiB | 1.44 MiB/s, done.
# Resolving deltas: 100% (3188/3188), done.
$ cd emsdk
$ ./emsdk install latest
# Resolving SDK alias 'latest' to '5.0.1'
# Resolving SDK version '5.0.1' to 'sdk-releases-bf32ae8b61ac8efeb7eca01b54c8307f992724f7-64bit'
# Installing SDK 'sdk-releases-bf32ae8b61ac8efeb7eca01b54c8307f992724f7-64bit'..
# Installing tool 'node-22.16.0-64bit'..
# Downloading: /home/nayaabh/emsdk/downloads/node-v22.16.0-linux-x64.tar.xz from https://storage.googleapis.com/webassembly/emscripten-releases-builds/deps/node-v22.16.0-linux-x64.tar.xz, 30425588 Bytes
# Unpacking '/home/nayaabh/emsdk/downloads/node-v22.16.0-linux-x64.tar.xz' to '/home/nayaabh/emsdk/node/22.16.0_64bit'
# Done installing tool 'node-22.16.0-64bit'.
# Installing tool 'releases-bf32ae8b61ac8efeb7eca01b54c8307f992724f7-64bit'..
# Downloading: /home/nayaabh/emsdk/downloads/bf32ae8b61ac8efeb7eca01b54c8307f992724f7-wasm-binaries.tar.xz from https://storage.googleapis.com/webassembly/emscripten-releases-builds/linux/bf32ae8b61ac8efeb7eca01b54c8307f992724f7/wasm-binaries.tar.xz, 342144156 Bytes
# Unpacking '/home/nayaabh/emsdk/downloads/bf32ae8b61ac8efeb7eca01b54c8307f992724f7-wasm-binaries.tar.xz' to '/home/nayaabh/emsdk/upstream'
# Done installing tool 'releases-bf32ae8b61ac8efeb7eca01b54c8307f992724f7-64bit'.
# Done installing SDK 'sdk-releases-bf32ae8b61ac8efeb7eca01b54c8307f992724f7-64bit'.
$ ./emsdk activate latest
# Resolving SDK alias 'latest' to '5.0.1'
# Resolving SDK version '5.0.1' to 'sdk-releases-bf32ae8b61ac8efeb7eca01b54c8307f992724f7-64bit'
# Setting the following tools as active:
#    node-22.16.0-64bit
#    releases-bf32ae8b61ac8efeb7eca01b54c8307f992724f7-64bit

# Next steps:
# - To conveniently access emsdk tools from the command line,
#   consider adding the following directories to your PATH:
#     /home/nayaabh/emsdk
#     /home/nayaabh/emsdk/upstream/emscripten
# - This can be done for the current shell by running:
#     source "/home/nayaabh/emsdk/emsdk_env.sh"
# - Configure emsdk in your shell startup scripts by running:
#     echo 'source "/home/nayaabh/emsdk/emsdk_env.sh"' >> $HOME/.bash_profile # 👈🏼 We will do this
$ echo 'source "/home/nayaabh/emsdk/emsdk_env.sh"' >> $HOME/.bash_profile
$ emcc --version
# shared:INFO: (Emscripten: Running sanity checks)
# emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 5.0.1 (8c5f43157a3f069ade75876e23061330521eabde)
# Copyright (C) 2026 the Emscripten authors (see AUTHORS.txt)
# This is free and open source software under the MIT license.
# There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ which emcc
# /home/nayaabh/emsdk/upstream/emscripten/emcc

Now here's where we get clever. Our C++ code currently has main() and iostream -- stuff that makes sense on a terminal but is dead weight in a browser. We want the same source file to work for both native compilation (for testing) and WASM compilation (for the browser). The solution? Good old preprocessor macros.

Emscripten automatically defines __EMSCRIPTEN__ during compilation, so we can use it to conditionally include or exclude code:

  • #ifdef __EMSCRIPTEN__ ... #endif -- includes code only when compiling with Emscripten. We use this to pull in the Emscripten header.
  • #ifndef __EMSCRIPTEN__ ... #endif -- excludes code when compiling with Emscripten. Perfect for hiding main() from the WASM build.
  • EMSCRIPTEN_KEEPALIVE -- this one's crucial. When we remove main(), the compiler's optimizer thinks "nobody calls runLoop, so I'll just delete it." This annotation tells the compiler: "Keep this function alive, someone will call it from the outside." Think of it as a // don't touch this comment that the compiler actually respects.
cppLooper.cpp
#include <iostream>
#include <cstdlib>
#include <cstdint>

#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h> 
#endif

extern "C"
{
    #ifdef __EMSCRIPTEN__
        EMSCRIPTEN_KEEPALIVE
    #endif
    std::uint64_t runLoop(std::uint64_t N)
    {
        std::uint64_t sum = 0;
        for (std::uint64_t i = 0; i <= N; ++i)
        {
            // Some fake work, just to demonstrate a CPU intensive work.
            sum += i;
        }
        return sum;
    }
}

#ifndef __EMSCRIPTEN__
int main(int argc, char **argv)
{

    char *end = nullptr;
    const std::uint64_t N = std::strtoull(argv[1], &end, 10);
    std::cout << runLoop(N) << std::endl;
    return 0;
}
#endif

Finally! The moment we've been building towards -- let's compile our C++ code to WebAssembly. This one command does all the magic:

$ emcc ./cppLooper.cpp -O2 -DNDEBUG -sWASM=1 -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web  -sEXPORTED_RUNTIME_METHODS=['cwrap','ccall'] -o ./cppLooper.js
$ ls -lrt
# ...
# -rw-r--r-- 1 nayaabh devyin   701 Feb 18 01:53 cppLooper.cpp
# -rwxr-xr-x 1 nayaabh devyin   439 Feb 18 01:58 cppLooper.wasm # 🎉
# -rw-r--r-- 1 nayaabh devyin  9310 Feb 18 01:58 cppLooper.js   # 🎉

That's a beefy command, so let me break down the Emscripten-specific flags (we already covered -O2 and -DNDEBUG earlier):

  • -sWASM=1: Compile to WebAssembly (as opposed to asm.js, the older fallback)
  • -sMODULARIZE=1: Wraps the generated JS in a factory function instead of polluting the global scope. Essential for any modern application.
  • -sEXPORT_ES6=1: Generates an ES6 module with export default. This means we can import it directly in our React code -- no script tags, no global variables.
  • -sENVIRONMENT=web: Tells Emscripten to only include browser-specific runtime code, keeping the output lean. If you need Node.js support too, you'd use -sENVIRONMENT=web,node.
  • -sEXPORTED_RUNTIME_METHODS=['cwrap','ccall']: Exposes two helper methods. cwrap creates a reusable JS wrapper around a C function, ccall calls a C function directly. We'll use cwrap in our React component.
  • -o ./cppLooper.js: Output filename. Emscripten is smart enough to generate both cppLooper.js (the glue code) and cppLooper.wasm (the binary) from this.

Look at that output -- 439 bytes for the WASM binary! That's smaller than most PNG favicons. The JS glue code is a bit larger at ~9KB, but that includes all the module loading, memory management, and function wrapping logic.

Chapter 4: Loading WASM in a React Component

Alright, time to leave the C++ terminal behind and teleport into the Javascript realm 🚀. This is where all our hard work pays off -- we're going to call our C++ function from a React component as if it were just another async function.

First things first -- let's copy our compiled artifacts into the public/wasm/ directory. This ensures they get served as static files by our dev server and bundled in our production build:

$ mkdir -p public/wasm
$ cp ./cppLooper.wasm ./public/wasm/
$ cp ./cppLooper.js ./public/wasm/

Remember that -sEXPORT_ES6=1 flag we used during compilation? This is where it pays off. Our cppLooper.js is a proper ES6 module, so we can import it directly -- no script tag hacks, no window.Module globals, none of that legacy nonsense.

We'll use cwrap (rather than ccall) because it gives us a reusable function reference. ccall calls the C function immediately and returns the result -- great for one-off calls. But cwrap returns a regular JS function that we can call over and over without re-wrapping each time. For a UI component where the user might click "Calculate" multiple times, cwrap is the obvious choice.

Here's the loading pattern:

type WasmModule = {
  cwrap: (
    name: string,
    returnType: "bigint",
    argTypes: "bigint"[],
  ) => (...args: (number | string | boolean | bigint)[]) => number | string | boolean | bigint | void;
};

type WasmFactory = (options?: { locateFile?: (path: string) => string }) => Promise<WasmModule>;

const WASM_BASE = "/wasm"; // /public/wasm
const WASM_MODULE = `${WASM_BASE}/cppLooper.js`; // Path to the generated JS glue code

// Dynamically import the generated JS glue code to load the wasm module
const moduleImport = await import(/* webpackIgnore: true */ WASM_MODULE);
// Create the module instance by calling the default export of the imported module
const createModule = moduleImport.default;
// Load the wasm module and get the exported functions ready to use
const cppLooperModule = await createModule({
  locateFile: (path) => `${WASM_BASE}/${path}`,
});                      

// Use cwrap to get a callable version of the exported "runLoop" function
const runLoop = cppLooperModule.cwrap("runLoop", "bigint", ["bigint"]) as (n: bigint) => bigint;
// Call the runLoop function with an input and log the result
const result = runLoop(100n);
console.log(result.toString()); // 5050n

Demo -- WASM vs JS Performance Comparison

Enough code, let's see it in action! Below is a live demo running right here on this page. Try it out -- enter a large number (try 100,000,000 if you're feeling brave) and watch the WASM version smoke the Javascript implementation:

WASM vs JS Performance comparison
Calculate sum of firstnumbers.
Sum🤷🏼❓
Press the buttons to run the calculation

Pretty wild, right? The bigger the number, the more dramatic the difference. That's WASM flexing its near-native performance muscles -- no JIT warmup, no garbage collector pauses, just raw compiled code doing what it does best.

Why does the calculation take longer in wasm for the first call?

This is because we load the wasm after you click the calculate button. Once it's loaded, it uses the same module reference for next iterations.

It doesn't really have to be lazy loaded like this. You can load it as soon as the page loads. Since I am using NextJS and want to demo wasm in an almost static blog page, I chose to load the wasm only if you are interested to try out the demo.

Why is Javascript calls faster than WASM for small number?

There is an overhead to pass data between wasm <-> js context, so it takes few ticks to get the results. But this overhead is constant and stays flat no matter how big the number is.

What's coming next? 🔮

This was just the beginning. We've covered the fundamentals -- but real-world WASM usage gets a lot more interesting (and a lot more tricky). I'm turning this into a series, and here's what's on my drafting board:

  1. Callbacks from C++ to JS -- What if your C++ code needs to notify the UI mid-computation? We'll explore function pointers and callback patterns.
  2. Passing complex data -- Arrays, objects, strings... memory management between JS and WASM is where things get spicy. We'll dive deep into the Emscripten heap.
  3. Build & Deploy pipeline -- How to integrate WASM compilation into your CI/CD pipeline (you know I love a good pipeline setup 😉).
  4. Real-world benchmarks -- I'll take a real production use case and benchmark WASM vs JS with proper profiling. No toy examples.
  5. The WASM ecosystem -- WASI, Component Model, and where WebAssembly is headed beyond the browser.

Each of these deserves its own deep-dive, and I've already started drafting them. Follow along and let me know in the comments which one you want to see first!

ANAbhilash Nayak
Last Updated on: 18-02-2026