The toolchain: Emscripten over raw LLVM
Getting existing C or C++ code running in the browser used to mean giving up. WebAssembly (Wasm) changes that, but there's a right way and a painful way to get there. The LLVM backend for Wasm can compile simple programs, but the moment you need the C standard library, multiple source files, or an operating system's facilities, you'll hit a wall.
The practical answer is Emscripten. It started as a C-to-asm.js compiler but has matured into a full Wasm toolchain that is in the process of switching to the official LLVM backend internally. Beyond that, Emscripten provides a Wasm-compatible implementation of C's standard library, emulates a file system, manages memory, and wraps OpenGL with WebGL—a lot of infrastructure you don't want to build yourself.
Worrying about the bloat this brings is natural, but the compiler strips everything that isn't needed. In practice, the resulting Wasm modules are reasonably sized for the logic they contain, and both the Emscripten and WebAssembly teams are working on further size reductions.
You can install Emscripten from its website or via Homebrew. If you prefer Docker, a well-maintained image is available:
$ docker pull trzeci/emscripten
$ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>
A minimal compile: Fibonacci in Wasm
Let's start with a simple C function that computes the nth Fibonacci number:
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int fib(int n) {
if(n <= 0){
return 0;
}
int i, t, a = 0, b = 1;
for (i = 1; i < n; i++) {
t = a + b;
a = b;
b = t;
}
return b;
}
The EMSCRIPTEN_KEEPALIVE macro from the emscripten.h header is critical here. It tells the compiler not to remove a function that appears unused—without it, the optimizer would eliminate the function entirely since nothing in the C code calls it.
Save this as fib.c and compile it with Emscripten's emcc command:
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c
Let's unpack those flags:
-s WASM=1: produce Wasm instead of asm.js.-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]': keep thecwrap()helper available in the generated JavaScript.-O3: aggressive optimization. Lower levels build faster but produce larger bundles because more unused code survives.
The build yields two files: a.out.js and a.out.wasm. The Wasm module is the compiled C code and is fairly small. The JavaScript file loads and initializes the module, sets up the stack, the heap, and other operating-system-like functionality C expects—it's about 19KB (~5KB gzipped).
Calling the compiled function
The simplest way to run your module is to load the generated JavaScript file. That gives you a global Module object, and its cwrap method lets you create a JavaScript-native wrapper that handles parameter conversion and invokes the C function. The arguments are the function name, return type, and argument types, in that order:
<script src="a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
const fib = Module.cwrap('fib', 'number', ['number']);
console.log(fib(12));
};
</script>
Running this logs 144 to the console—the 12th Fibonacci number.
A real library: compiling libwebp
Writing C code specifically for Wasm is a constrained exercise. The real value is taking an existing ecosystem of C libraries and using them on the web. These libraries depend on C's standard library, often an operating system, and frequently a file system. Emscripten provides most of this, with some documented limitations.
A practical goal is compiling the WebP encoder, written in C and available on GitHub along with API documentation:
$ git clone https://github.com/webmproject/libwebp
Start by exposing WebPGetEncoderVersion() from encode.h. Write a small C file named webp.c:
#include "emscripten.h"
#include "src/webp/encode.h"
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetEncoderVersion();
}
This is a good test case because it requires no parameters or complex data structures—just proof that the library's source compiles. To build it, point the compiler at libwebp's header files with the -I flag and pass in the C files the library needs. Passing all of libwebp's C files and letting the compiler strip the unused ones works well:
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-I libwebp \
webp.c \
libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c
With a simple HTML page and script, you can load and call the module:
<script src="https://web.dev/a.out.js"></script>
<script>
Module.onRuntimeInitialized = async (_) => {
const api = {
version: Module.cwrap('version', 'number', []),
};
console.log(api.version());
};
</script>
The output shows the version number:

Moving image data into Wasm
Version numbers are fine, but encoding an actual image demonstrates the real value. The question is how to get image data from JavaScript into Wasm land.
Looking at libwebp's encoding API, it expects an array of bytes in RGB, RGBA, BGR, or BGRA order. The Canvas API's getImageData() returns a Uint8ClampedArray containing exactly that data in RGBA format:
async function loadImage(src) {
// Load image
const imgBlob = await fetch(src).then((resp) => resp.blob());
const img = await createImageBitmap(imgBlob);
// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
With that data in hand, the task is copying it from JavaScript's memory into Wasm's. This requires two additional exposed functions—one to allocate memory inside Wasm land, and one to free it:
EMSCRIPTEN_KEEPALIVE
uint8_t* create_buffer(int width, int height) {
return malloc(width * height * 4 * sizeof(uint8_t));
}
EMSCRIPTEN_KEEPALIVE
void destroy_buffer(uint8_t* p) {
free(p);
}
create_buffer allocates space for the RGBA image, needing 4 bytes per pixel. The malloc() return value is a pointer that crosses to JavaScript land as a plain number. Once cwrap exposes the function, that number helps locate the buffer start, allowing the image data to be copied:
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);
Encoding and retrieving the result
The image is now in Wasm memory, ready for WebPEncodeRGBA from the WebP documentation. This function accepts a pointer to the input image, its dimensions, and a quality value from 0 to 100. It allocates an output buffer itself, to be released with WebPFree() after use.
Since the encoding operation returns both a buffer pointer and its length, and C functions cannot return arrays without dynamic allocation, a static global array is a pragmatic shortcut—not elegant C, but workable:
int result[2];
EMSCRIPTEN_KEEPALIVE
void encode(uint8_t* img_in, int width, int height, float quality) {
uint8_t* img_out;
size_t size;
size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);
result[0] = (int)img_out;
result[1] = size;
}
EMSCRIPTEN_KEEPALIVE
void free_result(uint8_t* result) {
WebPFree(result);
}
EMSCRIPTEN_KEEPALIVE
int get_result_pointer() {
return result[0];
}
EMSCRIPTEN_KEEPALIVE
int get_result_size() {
return result[1];
}
Now everything is in place to call the encoder, capture the pointer and size, copy them into JavaScript land, and clean up all Wasm-side buffers:
api.encode(p, image.width, image.height, 100);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
api.free_result(resultPointer);
Large images may trigger a memory growth failure where Wasm cannot expand enough to accommodate both input and output:

The fix is in the error message itself: add -s ALLOW_MEMORY_GROWTH=1 to the compile command.
That's it. The compiled WebP encoder successfully transcoded a JPEG to WebP. To confirm the result, convert the buffer into a blob and use it with an <img> element:
const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);
The result is a working WebP image:

Worth the effort
Bringing a C library into the browser is not a walk in the park. Understand the data flow and the overall process first—the rest becomes tractable. Wasm opens many new possibilities for processing, number crunching, and gaming on the web, but it is not a silver bullet. For bottleneck tasks, though, it is an incredibly useful instrument.
Going Without the Glue
If you'd rather not use the JavaScript file that Emscripten generates, you can still load and run a compiled module yourself. For the Fibonacci example, that means putting together a small loader by hand:
<!DOCTYPE html>
<script>
(async function () {
const imports = {
env: {
memory: new WebAssembly.Memory({ initial: 1 }),
STACKTOP: 0,
},
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/a.out.wasm'),
imports,
);
console.log(instance.exports._fib(12));
})();
</script>
Compiled Wasm modules are isolated by design: they have no access to anything outside of the imports object passed as the second argument to instantiateStreaming. Code produced by Emscripten expects specific entries in that object to function at all.
The first and most important is env.memory. A Wasm instance has no built-in memory; it needs to be given a WebAssembly.Memory object, which represents a piece of linear memory that can optionally grow. The size parameters are expressed in units of WebAssembly pages, where each page is 64 KiB. The example above allocates one page. If no maximum is specified, the memory can grow without an explicit limit in theory, though Chrome currently caps it at 2 GB. Most modules won't need to set a maximum.
Second, Emscripten modules look for env.STACKTOP, which marks where the stack begins. The stack handles function calls and local variable storage. For the minimal Fibonacci program here, there's no dynamic memory management involved, so the whole memory region can serve as the stack by setting STACKTOP = 0.



