Understanding the start Function
This page answers one question: does any of a module’s code run during instantiation — before you call an export — and if so, what can that code safely do?
Prerequisites
- [ ] Familiarity with instantiation, as in the Wasm instantiation lifecycle overview.
- [ ] WABT or
wasm-toolsto see whether a module has a start section.
Instantiation runs code in a fixed order
WebAssembly.instantiate does more than bind imports. In order, it creates the instance’s memories, tables and globals, binds the imports,
writes every active data segment into memory, writes every active element segment into tables, and finally — if the module has a start
section — calls the function that section names. Only after the start function returns does instantiate resolve and hand you the exports.
The start function is the module’s own initialiser: a function with no parameters and no results that the engine calls exactly once per
instance. It exists so a module can set up state that cannot be expressed as static data — computing a table, initialising an allocator,
registering something with an import. Because it runs before the exports exist from the host’s point of view, it has some sharp edges: if it
traps, instantiation fails with a RuntimeError and you never get an instance, and anything it calls must already be fully usable.
Step 1 — see whether a module has a start function
wasm-objdump -x app.wasm | grep -A1 '^Start'
wasm-tools print --skeleton app.wasm | grep '(start'
Start:
- start function: 37
Many compiled modules have no start section. Toolchains often prefer an exported initialiser that the glue calls explicitly, because a start function runs at a moment when the glue is not ready, as the next steps show.
Step 2 — write one in WAT
(module
(import "env" "log" (func $log (param i32)))
(global $table_ready (mut i32) (i32.const 0))
(memory (export "memory") 1)
(func $init
;; build a small lookup table: squares of 0..255 as i32
(local $i i32)
(loop $fill
(i32.store (i32.mul (local.get $i) (i32.const 4)) (i32.mul (local.get $i) (local.get $i)))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $fill (i32.lt_u (local.get $i) (i32.const 256))))
(global.set $table_ready (i32.const 1))
(call $log (i32.const 256)))
(start $init)
(func (export "square") (param i32) (result i32)
(i32.load (i32.mul (local.get 0) (i32.const 4)))))
const { instance } = await WebAssembly.instantiateStreaming(fetch("start.wasm"), {
env: { log: (n) => console.log("initialised", n, "entries") },
});
console.log(instance.exports.square(12)); // 144
initialised 256 entries is logged before instantiateStreaming resolves — the import was called from inside instantiation.
Step 3 — know what the start function cannot do
The start function runs with the instance half-born from the host’s perspective, which constrains it:
- It cannot be given arguments and cannot return results.
- Imports it calls cannot reach the module’s exports, because JavaScript does not have the
instanceyet. An import that doesinstance.exports.memorythrows, sinceinstanceis still undefined in the host code. - If it traps or an import it calls throws, instantiation fails and no instance is returned; any state it created is discarded.
- It runs on whatever thread called
instantiate— the main thread, typically — so long-running initialisation blocks the page.
The second point is the one that bites in practice: imports that log a string from the module’s memory need the memory, and with an exported memory the host cannot reach it yet. Providing the memory as an import avoids that, as described in providing memory at instantiation.
Step 4 — recognise the toolchain conventions
Toolchains mostly avoid the start section and use exported functions with conventional names instead.
_start is WASI’s entry point for commands: programs with a main. It is an export, not a start section, so instantiation does not run it;
the WASI host calls it, it runs main, and the program exits through proc_exit. Running it twice is not supported.
_initialize is WASI’s entry point for reactors: libraries that are instantiated and then called repeatedly. The host calls _initialize
once after instantiation to run static constructors, then calls other exports as needed. Rust and C reactors built with -mexec-model=reactor
export it.
wasm-bindgen’s start is a Rust function marked #[wasm_bindgen(start)]. The generated glue calls it after instantiation, once the glue’s
own state is ready, which is why it can freely use web_sys, log through imports, and install panic hooks — things a real start section could
not do safely.
Emscripten runs static constructors and main from its JavaScript runtime after instantiation, with INVOKE_RUN controlling whether main
runs automatically.
wasm-objdump -x -j Export app.wasm | grep -E '"_start"|"_initialize"|"__wbindgen_start"'
Step 5 — put heavy initialisation where it belongs
Whatever the mechanism, initialisation runs on the thread that instantiates the module. Building a large table, parsing embedded data or warming caches at start can add tens or hundreds of milliseconds to the moment the page gets its instance — on the main thread, that is a frozen page. Move heavy work to build time where possible, generating tables into data segments; lazily initialise what is rarely needed; and instantiate in a worker when initialisation is unavoidably heavy, as in compiling Wasm in a worker to free the main thread.
Debugging a failing initialiser
When initialisation fails, the error arrives in an awkward place. A trap inside a start function rejects instantiate with a RuntimeError, and
because no instance was returned, you cannot call anything to inspect its state. Two techniques make these failures tractable. First, during
development, temporarily move the start function’s body into an exported init and remove the start section — then instantiate, set
breakpoints, and call init from the console, where the debugger and the module’s exports are available. Second, have the initialiser report
progress through a logging import, so the last message before the trap tells you how far it got.
For toolchain conventions — _initialize, wasm-bindgen’s start, Emscripten’s constructors — the failure comes from an ordinary export call, so
normal debugging applies; the important habit is to catch and report it separately from later failures, because an instance whose initialiser
failed should not be used at all. The broader rules for traps are in
catching Wasm traps in JavaScript.
Why the start section exists at all
Given how many caveats it has, it is fair to ask why the start section exists. It solves a problem for modules that must be correct from the moment they exist — modules imported by other modules through ESM integration, for example, where there is no host code to call an initialiser, or modules in environments that instantiate without any glue. For those, a start function guarantees that no export can be called on an uninitialised instance. Hand-written modules and small runtimes use it for that guarantee. Large toolchains prefer the explicit initialiser because it composes better with glue that needs to be ready first. Both are legitimate; knowing which one a module uses explains when its code first runs.
Expected output
For the WAT example, the console shows the log from inside instantiation and then the result of the first export call:
initialised 256 entries
144
Gotchas
- Import touching
instance.exportsduring start. The instance does not exist yet in JavaScript. Use an imported memory, or move work to an exported initialiser. - Instantiation hangs the page. A heavy start function runs synchronously inside instantiation. Move it to a worker or to build time.
RuntimeErrorfrominstantiate. The start function trapped. There is no instance to inspect; reproduce by moving the work into an export.- Calling
_starttwice. WASI commands are run-to-completion; instantiate again for a second run.
Performance note
In one module, a start function that built a 1 MB lookup table at instantiation took 11 ms on a phone. Generating the same table at build time into a data segment moved the cost to a 1 MB memory copy during instantiation — under 1 ms — at the price of a module 1 MB larger before compression and about 12 KB larger after, because the table was highly regular.
Frequently Asked Questions
Can a module have more than one start function? No — at most one start section, naming one function. That function can call others.
Does the start function run again if I re-instantiate? Yes — once per instance. Compiling is shared; initialisation is per instance.
Does instantiateStreaming run start too?
Yes. Every instantiation path runs data segments, element segments and the start function before resolving.
Can the start function be async? No. It runs synchronously; anything asynchronous must be started from an export after instantiation.
Related
- Instantiating one module many times — per-instance initialisation cost.
- Handling CompileError and LinkError — what a trapping start looks like.
- Compiling Rust to wasm32-wasip1 —
_startin WASI commands. - Using bulk memory operations — lazy data initialisation instead of start-time work.
← Back to Wasm Instantiation Lifecycle