Running Lua in the Browser with Wasm

This page answers one task: an application wants user- or designer-editable logic — game behaviour, configuration rules, automation scripts, mods — and Lua is the language its authors know or the language existing scripts are written in. You want a real Lua interpreter running in the browser, isolated from the page, with a controlled API and limits on what scripts can do.

Prerequisites

  • [ ] A Lua-in-Wasm package (for example wasmoon, which compiles the reference Lua 5.4 interpreter to WebAssembly) or your own Emscripten build of Lua.
  • [ ] A list of functions scripts are allowed to call.
  • [ ] Limits for script execution time and memory.

Why Lua, and why in Wasm

Lua is designed for embedding: a small interpreter (the reference implementation is a few hundred kilobytes of C), a simple C API, and a long history as the scripting language of games, editors and network tools. Many teams already have Lua scripts — game mods, Neovim-style configuration, Redis or Nginx scripts — and want to run them in a web version of their tool.

Compiled to WebAssembly, the reference interpreter runs unchanged, with exact Lua semantics (integers, string handling, coroutines, metatables), unlike reimplementations in JavaScript such as Fengari, which trade speed and exactness for tighter JavaScript integration. The Wasm interpreter also has its own heap inside linear memory, so scripts cannot reach the page’s objects except through functions you expose.

Fengari versus a Wasm-compiled Lua Fengari reimplements Lua in JavaScript, integrating closely with JavaScript objects and the garbage collector but running slower with some semantic differences. A Wasm build of the reference interpreter has exact Lua 5.4 semantics and better speed, with its own heap and an explicit boundary for passing values. Fengari (Lua in JS) shares the JS heap easy JS object access slower, some differences tight JS integration Lua compiled to Wasm reference Lua 5.4 semantics own heap, explicit boundary faster interpretation compatibility, isolation

Step 1 — create an engine and run code

import { LuaFactory } from "wasmoon";

const factory = new LuaFactory();
const lua = await factory.createEngine({ openStandardLibs: true });
const result = await lua.doString(`
  local function fib(n) if n < 2 then return n end return fib(n-1) + fib(n-2) end
  return fib(20)
`);
console.log(result);          // 6765
lua.global.close();           // free the interpreter when done

The factory loads the Wasm module once; each engine is a separate Lua state with its own globals. Create one engine per script context (per user, per mod) so scripts cannot interfere with each other.

Step 2 — expose a narrow host API

Set global functions and tables that scripts may use:

lua.global.set("game", {
  spawn: (kind, x, y) => world.spawn(String(kind), Number(x), Number(y)),
  log: (msg) => console.log(`[mod] ${String(msg).slice(0, 500)}`),
});
await lua.doString(`
  for i = 1, 3 do game.spawn("coin", i * 32, 100) end
  game.log("spawned coins")
`);

Values cross the boundary by conversion: numbers, strings and booleans map directly; Lua tables become JavaScript objects or arrays when returned; JavaScript functions become callable from Lua. Validate everything that comes from scripts — they are untrusted input — and keep exposed functions coarse-grained, since each call crosses the boundary.

Step 3 — remove dangerous standard libraries

The full standard library includes io, os and require/package, which in a Wasm build map to Emscripten’s virtual file system and environment. They cannot reach the user’s real files, but they are unnecessary surface for scripts. Open only the libraries scripts need (base, string, table, math, coroutine, utf8) or remove globals after creation:

await lua.doString(`io = nil; os = nil; package = nil; require = nil; dofile = nil; loadfile = nil; load = nil`);

Removing load prevents scripts from compiling new code from strings, which matters if scripts are combined from several sources. Freeze the API table with a metatable so scripts cannot replace your functions for other scripts sharing the state.

Running a user mod in a Lua sandbox The factory loads the Lua Wasm module once. For each mod, a new Lua state is created with a minimal standard library, dangerous globals removed and a frozen host API table. The mod's script runs with an instruction budget enforced by a debug hook, and its calls to the host API are validated before acting on the page. load Lua Wasm once factory new state per mod own globals minimal stdlib no io/os/load run with budget debug hook validated host calls game.spawn, log

Step 4 — limit execution time

An infinite loop in a script blocks the thread running the interpreter. Lua’s debug hook can count instructions and abort when a budget is exceeded; set it from the host before running untrusted code (wasmoon and similar wrappers expose ways to install hooks or interrupts — check the current API), or run scripts in a worker and terminate the worker if a deadline passes. Combining both gives a cooperative limit for normal cases and a hard stop as a backstop.

Step 5 — control memory

A script can allocate large tables or strings until the Wasm module runs out of memory. Lua supports a custom allocator function; builds that route allocation through a counting allocator can refuse allocations beyond a per-state budget, which surfaces in Lua as a memory error the host can catch. Without that, terminate the worker if its memory grows beyond a threshold.

Performance expectations

The Lua interpreter in Wasm runs scripts at a fraction of native Lua speed — typically within a factor of two to three — and, for most scripting workloads, faster than reimplementations in JavaScript. LuaJIT’s JIT compiler cannot run in Wasm (it generates machine code), so code tuned for LuaJIT’s speed will be slower. Scripting workloads are usually dominated by host API calls rather than Lua execution; keep those calls coarse.

Coroutines and async host functions

Lua coroutines work inside the interpreter. Some wrappers also let host functions return promises that Lua code awaits as if they were synchronous, by suspending the Lua coroutine until the promise resolves. That is convenient for scripts that fetch data, but it means scripts may interleave with other JavaScript; design the host API so state changes are atomic per call.

Sharing scripts between web and native versions

A common reason to choose Lua is that the same scripts run in a native application — a desktop game, a server, an embedded device — that embeds the reference interpreter. Using the same interpreter version compiled to Wasm keeps behaviour identical: integer and float semantics, string formatting, table iteration order for the parts Lua defines, and error messages. Keep the host API identical too, by defining it once (names, argument types, return values) and implementing it for both hosts, ideally with a shared test suite of scripts that runs against the native host in CI and against the browser host in a headless browser. Differences that do arise are usually in the host functions, not in Lua, and a shared suite finds them before mod authors do.

Error reporting for script authors

People writing scripts need good errors: the script name, the line, and the message, plus a traceback for runtime errors. Load each script with a chunk name (--[[script name]] via the loader’s name argument) so errors show “coins.lua:12” rather than a generic chunk label, and catch errors on the JavaScript side with the traceback from debug.traceback included. Show errors in an in-app console for mod authors rather than only in the browser’s developer tools, and keep the game running when one mod fails — disable that mod and report the error, so a bug in one script does not take down the whole experience.

Saving script state

Mods often need to persist small amounts of data. Expose a storage API scoped per mod (a key-value function backed by IndexedDB on the web), with size limits, rather than giving scripts the io library.

Expected output

User mods written in Lua 5.4 run in separate interpreter states in a worker, with io, os and load removed; mods call a frozen game API that validates arguments; a runaway loop is stopped by an instruction budget within 50 ms; memory per state is capped; and the same mods run unchanged in the desktop version of the game, which embeds native Lua.

Gotchas

  • Opening the full standard library. Unneeded surface. Open only what scripts need.
  • Leaving load available. Scripts can compile arbitrary code. Remove it unless required.
  • One shared state for all scripts. Scripts interfere. Use a state per context.
  • No execution limit. Infinite loops hang the thread. Use hooks and worker termination.
  • Expecting LuaJIT speed. The JIT cannot run in Wasm. Measure with the interpreter.
  • One failing mod stopping everything. Catch errors per script, disable the mod and report it.

Performance note

A benchmark of mod logic (table manipulation and string building) ran about 2.4× slower in the Wasm-compiled interpreter than native Lua 5.4 and about 3× faster than in a JavaScript reimplementation.

Relative run time of the same Lua benchmark Run time relative to native Lua 5.4 for a mod-logic benchmark in native Lua, in the reference interpreter compiled to Wasm, and in a JavaScript reimplementation of Lua. run time relative to native (lower is better) native Lua 5.4 1 × Lua compiled to Wasm 2.4 × Lua in JavaScript 7.2 ×

Frequently Asked Questions

Can scripts access the DOM? Only through functions you expose; prefer a domain API over raw DOM access.

Does require work for modules? You can implement a custom loader that resolves module names to sources you provide.

Can I precompile scripts? Lua bytecode can be loaded, but bytecode is not verified for safety; load source for untrusted scripts.

Is Luau or another dialect available? Other Lua dialects with C or C++ implementations, such as Luau, can be compiled to Wasm the same way.

How do I keep scripts behaving identically on web and desktop? Use the same Lua version and one host API definition, and run a shared script test suite against both hosts.

← Back to Other Languages in the Browser