Emitting ES Modules from Emscripten

This page answers one task: turn Emscripten’s default output — a script that defines a global Module — into an ES module with a factory function you can import, instantiate on demand, and use from a bundler or a worker.

Prerequisites

  • [ ] emsdk 3.1.x with emcc on the path.
  • [ ] A C or C++ library with functions you already export via EXPORTED_FUNCTIONS or Embind.
  • [ ] A consumer: a bundler such as Vite or webpack, a Web Worker, or Node 18+.

What the default output does wrong for modern apps

Without extra settings Emscripten emits a classic script. It assumes it is loaded with a <script> tag, creates a global Module object, starts loading the .wasm immediately, and finds that file relative to the page’s URL. Each of those assumptions breaks something: globals collide when two modules are on one page, eager loading wastes bandwidth for features the user never opens, and page-relative URLs fail once a bundler moves the file.

Three settings change the shape of the output. MODULARIZE wraps everything in a factory function that returns a Promise of an instance. EXPORT_ES6 makes the file an ES module with that factory as its default export. EXPORT_NAME names the factory. Together they produce a file that behaves like any other import.

Classic script output versus an ES module factory The default output defines a global and starts loading immediately. With MODULARIZE and EXPORT_ES6 the output exports a factory that loads only when called and can be instantiated more than once. default output global var Module on window loads app.wasm as soon as the script runs .wasm located relative to the page URL one instance per page fine for a demo page MODULARIZE + EXPORT_ES6 export default createApp nothing loads until createApp() is called .wasm located with import.meta.url any number of independent instances what bundlers and workers expect

Step 1 — build the module

emcc -O2 src/codec.c \
  -sMODULARIZE=1 \
  -sEXPORT_ES6=1 \
  -sEXPORT_NAME=createCodec \
  -sENVIRONMENT=web,worker \
  -sEXPORTED_FUNCTIONS=_encode,_decode,_malloc,_free \
  -sEXPORTED_RUNTIME_METHODS=HEAPU8,cwrap \
  -o dist/codec.mjs

Use the .mjs extension so Node and some tools treat it as a module without configuration. ENVIRONMENT strips the code paths for environments you do not target — a web-and-worker build omits Node’s fs and require handling, which saves a few kilobytes and removes a frequent source of bundler warnings about fs and path not being resolvable.

Step 2 — import and instantiate

import createCodec from "./dist/codec.mjs";

const codec = await createCodec();
const encode = codec.cwrap("encode", "number", ["number", "number"]);

const input = new Uint8Array(await (await fetch("/sample.raw")).arrayBuffer());
const ptr = codec._malloc(input.length);
codec.HEAPU8.set(input, ptr);
const outLen = encode(ptr, input.length);
codec._free(ptr);

The factory accepts an options object that pre-seeds the module. The two most useful entries are locateFile, for overriding where the .wasm is fetched from, and print/printErr, for routing stdout:

const codec = await createCodec({
  locateFile: (path) => new URL(`/static/wasm/${path}`, location.origin).href,
  printErr: (line) => console.warn("[codec]", line),
});

Step 3 — let the bundler find the .wasm

With EXPORT_ES6, Emscripten locates the binary with new URL('codec.wasm', import.meta.url). Vite, webpack 5, Rollup and esbuild all understand that pattern and copy the file into the output with a hashed name, as described in resolving Wasm URLs with import.meta.url.

How the .wasm file travels through a bundler The Emscripten output references the binary with new URL and import.meta.url. The bundler sees that pattern, emits the file with a content hash, and rewrites the URL, so the factory fetches the hashed file at runtime. codec.mjs new URL('codec.wasm', import.meta.url) bundler sees URL pattern Vite, webpack 5, Rollup emits codec-3f9a1c.wasm content-hashed asset rewritten URL factory fetches the hashed file

If your bundler is older and does not handle the pattern, fall back to locateFile and copy the file yourself. Do not reach for -sSINGLE_FILE as a first resort: inlining the binary as base64 inflates it by a third and defeats streaming compilation.

Step 4 — run it in a worker

The same factory works inside a module worker, which is where a CPU-heavy codec usually belongs:

// codec.worker.js
import createCodec from "./dist/codec.mjs";
const ready = createCodec();

self.onmessage = async ({ data }) => {
  const codec = await ready;
  const ptr = codec._malloc(data.length);
  codec.HEAPU8.set(data, ptr);
  const n = codec._encode(ptr, data.length);
  const out = codec.HEAPU8.slice(ptr, ptr + n);
  codec._free(ptr);
  self.postMessage(out, [out.buffer]);
};
const worker = new Worker(new URL("./codec.worker.js", import.meta.url), { type: "module" });

The ENVIRONMENT=web,worker setting from step 1 matters here; a build restricted to web checks for a window and fails in a worker. The broader pattern is covered in loading Wasm in a Web Worker with ESM.

Step 5 — load once, share everywhere

Because every call to the factory creates a fresh instance with its own memory, the usual application pattern is a module-level singleton: one Promise, created on first use, awaited by every caller. That keeps a page that has three components using the codec from downloading and compiling it three times, and it gives you one place to handle load failures.

// codec.js — the only file that imports the Emscripten output
import createCodec from "./dist/codec.mjs";

let instance;                       // Promise<CodecModule> | undefined

export function getCodec() {
  instance ??= createCodec().catch((err) => {
    instance = undefined;           // allow a retry after a network failure
    throw err;
  });
  return instance;
}

export async function encode(bytes) {
  const codec = await getCodec();
  const ptr = codec._malloc(bytes.length);
  try {
    codec.HEAPU8.set(bytes, ptr);
    const n = codec._encode(ptr, bytes.length);
    return codec.HEAPU8.slice(ptr, ptr + n);
  } finally {
    codec._free(ptr);
  }
}

Every other module imports encode rather than the factory, so the memory-management details — the malloc, the copy, the free in a finally — live in exactly one place. The reset in the catch matters more than it looks: without it, a single failed download on a flaky connection poisons the cached Promise and the feature stays broken until reload.

Step 6 — describe the factory to TypeScript

The generated .mjs has no types. A hand-written declaration file next to it is short and catches the most common mistake — using the instance before awaiting the factory:

// dist/codec.d.mts
export interface CodecModule {
  HEAPU8: Uint8Array;
  _malloc(size: number): number;
  _free(ptr: number): void;
  _encode(ptr: number, len: number): number;
  _decode(ptr: number, len: number): number;
  cwrap(name: string, ret: string | null, args: string[]): (...a: unknown[]) => unknown;
}
export interface CodecOptions {
  locateFile?: (path: string, prefix: string) => string;
  print?: (line: string) => void;
  printErr?: (line: string) => void;
}
declare const createCodec: (opts?: CodecOptions) => Promise<CodecModule>;
export default createCodec;

Keep the interface to the functions you actually call. Typing the whole Emscripten runtime is a large job that adds little; the narrow version documents the contract your wrapper depends on and fails the type check the day someone removes an export from the build flags.

Expected output

A quick inspection confirms the output is a real module with one default export:

head -c 200 dist/codec.mjs; echo; grep -c "export default" dist/codec.mjs
var createCodec = (() => {
  var _scriptName = import.meta.url;
  return (
async function(moduleArg = {}) {
1

In the browser, the Network panel should show codec.wasm requested only after createCodec() is called — not at page load.

For a Node consumer, the same .mjs file works if node was included in ENVIRONMENT; Node resolves import.meta.url to a file:// URL and the loader reads the binary from disk with fs rather than fetching it. That makes it possible to publish one package for both environments, a pattern covered in targeting Node and browsers from one Wasm package.

Gotchas

  • Module is not defined in your own code. With MODULARIZE there is no global; use the object the factory resolves to. Code that referenced Module directly, including --pre-js files, must use the moduleArg parameter or the returned instance.
  • ReferenceError: require is not defined. The build includes Node code paths and is running in a browser bundle. Set -sENVIRONMENT=web,worker.
  • Calling exports before the Promise resolves. The factory returns a Promise; accessing codec._encode on the Promise itself yields undefined. Always await createCodec() first.
  • Two instances, one memory expectation. Each call to the factory creates a new instance with its own memory. Cache the Promise if you want a singleton.
  • A 404 for codec.wasm only in production. The bundler did not recognise the URL pattern — often because a minifier rewrote import.meta.url or the output was post-processed by another tool. Check the built bundle for a hashed .wasm reference; if it is missing, supply locateFile explicitly.
Which ENVIRONMENT value for which consumer A matrix of Emscripten ENVIRONMENT settings against where the module will run, showing which combinations work. ENVIRONMENT page module worker Node 18+ web works fails: no window fails web,worker works works fails node fails fails works web,worker,node works works works, larger

Performance note

On a 420 KB codec, switching from the classic output to MODULARIZE + EXPORT_ES6 + ENVIRONMENT=web,worker shrank the JavaScript loader from 61 KB to 47 KB minified. The bigger win was behavioural: loading the factory lazily from the feature that needs it took 420 KB off the initial page load, which is the same lazy-loading idea discussed in preloading Wasm with link rel=preload — only fetch early what you will certainly use.

Frequently Asked Questions

Do I still need EXPORT_ES6 if my bundler handles CommonJS? It is still preferable. The ES module form lets the bundler see the import.meta.url reference and handle the binary; the CommonJS form hides it.

What does EXPORT_NAME change if the export is default? The name of the factory inside the file and in stack traces. It also names the global when the same file is loaded without module support, so pick something unambiguous.

Can TypeScript see the exports? Emscripten can emit a declaration file with --emit-tsd codec.d.ts for Embind bindings. For plain C exports, write a small .d.ts by hand describing the factory and the functions you use.

Should the factory live in the main bundle? Only if the feature is needed on first paint. Otherwise a dynamic import("./codec.js") in the code path that uses it splits the loader and the binary out of the initial download, and the bundler handles both.

How do I pass command-line arguments to main()? Set arguments in the options object — createCodec({ arguments: ["--quality", "80"] }) — and build with -sINVOKE_RUN=1 (the default) so main runs during instantiation. For library-style modules with no main, build with --no-entry and call exports instead.

← Back to C/C++ to Wasm with Emscripten