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
emccon the path. - [ ] A C or C++ library with functions you already export via
EXPORTED_FUNCTIONSor 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.
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.
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 definedin your own code. WithMODULARIZEthere is no global; use the object the factory resolves to. Code that referencedModuledirectly, including--pre-jsfiles, must use themoduleArgparameter 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._encodeon the Promise itself yieldsundefined. Alwaysawait 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.wasmonly in production. The bundler did not recognise the URL pattern — often because a minifier rewroteimport.meta.urlor the output was post-processed by another tool. Check the built bundle for a hashed.wasmreference; if it is missing, supplylocateFileexplicitly.
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.
Related
- Bundling Wasm ESM with Vite — the Vite side of the same setup.
- Binding C++ libraries with Embind — richer exports that work with the factory pattern.
- Generating TypeScript types from Wasm — typing the factory’s result.
- Lazy loading Wasm on first use — when to call the factory.
← Back to C/C++ to Wasm with Emscripten