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

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.

What happens inside instantiate, in order Instantiation creates memories, tables and globals, binds imports, writes active data segments, writes active element segments, and then calls the start function if one exists. Only after it returns does the promise resolve with the exports. create memory, tables, globals fresh state bind imports from the import object data segments static data written element segments tables filled start function runs once, then resolve

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 instance yet. An import that does instance.exports.memory throws, since instance is 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.

Initialising in a start function versus an exported initialiser A start function runs automatically inside instantiation but cannot take arguments, cannot use exports from imports, and makes instantiation fail if it traps. An exported initialiser is called explicitly afterwards, can take arguments and report errors, and runs with glue fully ready. start section runs automatically, once no arguments, no results imports cannot reach exports yet a trap fails instantiation tiny, self-contained setup exported initialiser called explicitly after instantiate can take config, return status glue and exports fully ready errors handled like any call most real initialisation

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.exports during 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.
  • RuntimeError from instantiate. The start function trapped. There is no instance to inspect; reproduce by moving the work into an export.
  • Calling _start twice. 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.

Building a lookup table at start versus at build time Instantiation time on a phone when a 1 MB lookup table is computed by the start function, and when it is precomputed at build time and stored in a data segment. ms added to instantiation on a phone computed in start function 11 ms precomputed data segment 0.8 ms

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.

← Back to Wasm Instantiation Lifecycle