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.
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.
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
loadavailable. 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.
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.
Related
- Running user scripts in a QuickJS Wasm sandbox — JavaScript scripting.
- Running Python in the browser with Pyodide — a larger interpreter.
- Limiting plugin CPU and memory use — resource limits.
- Designing a Wasm plugin interface — API design.
← Back to Other Languages in the Browser