Browser scripting lane: how to instantiate script modules

The design so far instantiates the script module synchronously inside File::import. The worry was that browsers refuse synchronous wasm compilation on the main thread. This note records what the browsers actually do today, what the runtime already offers for late script start, and the options.

What the browsers do (measured 2026-09-19 on this Mac)

Same probe in each engine: new WebAssembly.Module(bytes) then new WebAssembly.Instance(module), on the main thread and in a worker. Real modules are the AnimaScript module ABI fixture (27 KB) and the Luau host module vm_host.wasm (825 KB). Padded modules are a valid module with one large data segment, to find size limits. Times are milliseconds for three runs.

EngineThread27 KB AS825 KB Luau host8.1 MB64 MB
Chrome 153main0.3, 0.1, 0.11.5, 0.7, 1.0refusedrefused
Chrome 153worker0.2, 0.2, 0.10.6, 0.9, 0.610.782
Firefox 155main3, 2, 26, 9, 817
Firefox 155worker2, 1, 29, 7, 718
WebKit 26.6main0, 0, 02, 2, 1320
WebKit 26.6worker0, 1, 02, 1, 2112

What this says:

  • Workers have no limit in any engine.
  • Firefox and WebKit have no main thread limit either.
  • Chrome limits the main thread to 8 MB. The error reads “WebAssembly. Compile is disallowed on the main thread, if the buffer size is larger than 8MB”. The old limit was 4 KB; Chrome raised it (chromestatus feature 5099433642950656). Our largest module is a tenth of the new limit.
  • Synchronous compile is cheap because engines compile lazily or with a fast baseline first. Firefox is the slowest at 6 to 9 ms for the Luau host module, which is half a frame, once per file.
  • Asynchronous WebAssembly.compile took about the same time as the synchronous call in every case. Async does not make compile faster, it only moves the wait off the call stack.

Not measured: Chrome versions from before the limit was raised, where any module over 4 KB is refused on the main thread. Embedded Chromium (Android WebView, Electron) follows the Chrome version it ships. The WebKit build is Playwright's, which shares the JavaScript engine with Safari but is not Safari itself.

What the runtime already has for late script start

  • File::import finishes reading objects and then calls File::registerScripts(), which makes the VM, requires each script and calls ScriptAsset::registrationComplete(ref). Registration is already a separate step at the end of import, not woven through it.
  • The editor lane already starts scripts after import: File::adoptWasmScriptingVM swaps a VM built elsewhere into a file and File::applyWasmRegistration completes the script assets against it.
  • A scripted object with no ready backend is inert: initScriptedObject returns false and the object skips its calls.
  • Script init is attempted when an artboard instance initializes, when a data context is bound, and when a state machine instance is built. It is not retried per frame. So an instance created before its VM is ready stays inert unless something re-runs init (ScriptedObject::reinit). Images differ here: instances read the image from the shared asset at draw time, so a late image just appears.
  • Both web hosts already load files asynchronously. Dart‘s decodeRiveFile returns a Future and already awaits completedDecodingFile, the image decode wait. The JS runtime’s load returns a Promise.

Options

A. Synchronous inside import (the design as it stands). Works today in every current engine with ten times headroom. Fails on Chrome builds older than the 8 MB change, and the failure is total: no script runs.

B. Two step load at the host boundary. File::import stays synchronous and on the web stops before registerScripts. It leaves each VM created but not instantiated. The host glue, which is already async, asks JS to WebAssembly.instantiate every pending module, awaits them together with the image decodes, then calls one new entry point that boots the VMs and runs registerScripts. Only then does the app receive the File. No instance can exist before scripts are ready, so behaviour matches native exactly, no first frame differs, and no engine limit applies. Cost: one new C++ entry point, and hosts must call it. A host that skips the await can fall back to option A inside that entry point, so forgetting it degrades to today's design instead of breaking.

C. Lazy start, the way images work. Import returns at once, modules instantiate in the background, and scripted objects pick the VM up when it arrives. This needs a new per frame check (or a walk over live instances) so early instances get initialized late. The real cost is behaviour: for the first frames layouts are unsized, scripted drawables are blank, and converters and listeners do nothing, so a state machine can take a different path than it does on native. Images arriving late is cosmetic. Scripts arriving late changes logic.

D. Asynchronous File::import in C++. Changes the import API on every platform to solve a web only problem. B gets the same result without it.

E. Scripting on a worker. No limits anywhere, but VM calls, the JS slot table and recording are all per thread, and recording runs on the main thread today. Far larger than the problem.

Recommendation

B, with A as its built in fallback. It removes the dependence on any browser's sync limit, keeps web behaviour identical to native, and reuses the await the hosts already have. It also fits the decision that the editor or the high level runtime loads the JS glue, since that same glue is what awaits the modules.