Running Ruby in the Browser with ruby.wasm
This page answers one task: you want real Ruby — the reference CRuby interpreter, with its standard library and gems — running in a web page, for interactive tutorials, playgrounds, documentation examples or running existing Ruby logic client-side.
Prerequisites
- [ ] A web page or bundler setup, and
npm install @ruby/wasm-wasi @ruby/3.3-wasm-wasi(or the matching Ruby version package). - [ ] For gems, the
ruby_wasmRuby gem and a Ruby installation to build custom bundles. - [ ] Familiarity with Ruby.
What ruby.wasm is
ruby.wasm is the official project that compiles CRuby — the same C implementation used on servers — to WebAssembly with WASI. The interpreter, its
garbage collector and the standard library run inside the module; a small JavaScript runtime (@ruby/wasm-wasi) provides WASI through a browser shim and
a bridge (js library in Ruby) for calling JavaScript and the DOM. Because it is the real interpreter, Ruby code behaves exactly as it does natively,
including string encodings, exceptions and most of the standard library. Features that need an operating system — threads, sockets, subprocesses — are
unavailable or limited.
The trade-off is size and speed. The module includes the whole interpreter: tens of megabytes uncompressed with the full standard library, around 10 MB or less compressed for typical distributions, and less with a trimmed build. Startup takes a noticeable moment while the interpreter boots. That suits playgrounds and tools where Ruby is the point, and is heavy for small enhancements to an ordinary page.
Step 1 — run Ruby from a script tag
The quickest start is the browser script, which boots Ruby and runs <script type="text/ruby"> blocks:
<script src="https://cdn.jsdelivr.net/npm/@ruby/3.3-wasm-wasi@2/dist/browser.script.iife.js"></script>
<script type="text/ruby">
require "js"
doc = JS.global[:document]
doc.getElementById("out")[:innerText] = "Ruby #{RUBY_VERSION} says hello"
</script>
<p id="out"></p>
The script fetches the .wasm, instantiates the interpreter and evaluates each Ruby block in order. For production, self-host the files rather than
loading from a public CDN, pin exact versions, and add subresource integrity hashes to the script tag.
Step 2 — control the VM from JavaScript
For applications, create the VM yourself so JavaScript decides when to load and what to run:
import { DefaultRubyVM } from "@ruby/wasm-wasi/dist/browser";
const response = await fetch(new URL("@ruby/3.3-wasm-wasi/dist/ruby+stdlib.wasm", import.meta.url));
const module = await WebAssembly.compileStreaming(response);
const { vm } = await DefaultRubyVM(module);
vm.eval(`
def word_frequencies(text)
text.downcase.scan(/[a-z']+/).tally.sort_by { |_, n| -n }.first(5)
end
`);
const result = vm.eval(`word_frequencies(${JSON.stringify(text)})`);
console.log(result.toString()); // [["the", 12], ["wasm", 7], …]
vm.eval returns a RbValue; convert it with toString(), toJS() or by calling methods on it. Passing user text by interpolating JSON.stringify output
works because JSON strings are valid Ruby string literals; for anything more complex, set a variable through the bridge instead of building Ruby source
strings. Run the VM in a worker for long computations, as with any interpreter.
Keep one VM per page or worker and reuse it; booting a VM is much cheaper than compiling the module, but still takes hundreds of milliseconds on slower devices.
Step 3 — call JavaScript from Ruby
The js library exposes JavaScript objects as JS::Object, with property access through [] and method calls through call or method syntax:
require "js"
button = JS.global[:document].querySelector("#go")
button.addEventListener("click") do |event|
JS.global[:console].log("clicked at", event[:timeStamp])
end
response = JS.global.fetch("/api/items").await # await works inside an async evaluation context
items = JSON.parse(response.text.await.to_s)
await on a JavaScript Promise suspends the Ruby code until it resolves — ruby.wasm uses Asyncify-style stack switching or JSPI under the hood — so it
must run in an evaluation started with vm.evalAsync (script tags do this automatically). The mechanics resemble
calling async JavaScript with JSPI.
Step 4 — pack gems into the bundle
Pure-Ruby gems can be bundled into a custom .wasm with the rbwasm CLI from the ruby_wasm gem, which reads your Gemfile and packs the gems’ files
into the module’s file system:
gem install ruby_wasm
bundle add some_pure_ruby_gem
rbwasm build -o app.wasm # builds Ruby + stdlib + bundled gems
rbwasm pack app.wasm --dir ./lib::/app/lib -o app-packed.wasm # add your own source files
Gems with C extensions need to be compiled for WASI as part of the build; many popular ones are not yet supported, so check before relying on them. Prefer pure-Ruby alternatives in browser bundles.
Step 5 — reduce size and startup time
Use the smallest distribution that works: the packages offer builds with and without the full standard library, and rbwasm build can exclude
extensions you do not need. Precompress with Brotli, cache with long lifetimes, and compile with compileStreaming so compilation overlaps the download.
Start loading Ruby when the user opens the feature — the playground tab, the code example — rather than on page load, and show progress. Snapshotting an
initialised VM (Wizer-style pre-initialisation) can cut boot time further for specialised builds.
Building an interactive code runner
Playgrounds and tutorials share a pattern worth getting right. Run each reader’s snippet in a VM inside a worker, so an infinite loop does not freeze the
page, and add a timeout that terminates the worker and starts a fresh one when a snippet runs too long. Capture output by redirecting $stdout and
$stderr to StringIO objects before evaluation and returning their contents, or by hooking the WASI shim’s stdout to post lines to the page as they are
written. Reset state between runs by creating a new VM, which is cheap once the module is compiled, or by running each snippet in a fresh binding if
sharing definitions between examples is desirable. Show exceptions with their Ruby backtrace, since vm.eval raises a JavaScript error whose message
includes it. And keep the worker warm: compile the module and boot one VM as soon as the reader scrolls near the first example, so the first click on
“Run” responds instantly.
When ruby.wasm is the right choice
ruby.wasm shines where Ruby itself is the product or the content: an interactive Ruby tutorial where readers edit and run code in place; documentation for a Ruby library whose examples execute in the page; a playground for a Ruby-based DSL; or a tool whose rules engine is written in Ruby and must run client-side for privacy or offline use. It is the wrong tool for adding a small feature to a page in a team that happens to know Ruby — the download dwarfs any benefit, and JavaScript or a compact Wasm language would serve better. The same reasoning applies to other interpreted languages in the browser, such as Python with Pyodide, discussed in running Python in the browser with Pyodide. If the Ruby code is a stable library rather than user-editable code, consider whether it can be ported or whether a server endpoint is simpler.
Expected output
A documentation page boots Ruby in about 1.5 s on a fast connection after the first visit’s download, evaluates readers’ code examples, prints puts
output to an on-page console, and updates the DOM through JS.global — with behaviour identical to running the same code with native Ruby.
Gotchas
- Loading Ruby on every page. The download is large. Load it only where Ruby runs.
- Building Ruby source strings from user input. Quoting bugs and injection. Pass data through the bridge.
awaitoutside an async evaluation. UseevalAsyncor script tags.- Gems with C extensions. Many do not build for WASI yet. Prefer pure-Ruby gems.
- No timeout for reader code. An infinite loop hangs the runner. Run snippets in a worker you can terminate.
- Long computations on the main thread. The interpreter blocks the page. Use a worker.
Performance note
The ruby+stdlib build was about 9 MB with Brotli; a trimmed custom build without unused extensions was about 4 MB. Booting the VM took about 300 ms after
compilation on a laptop. CPU-bound Ruby ran roughly 1.5–3× slower than native CRuby without its JIT.
Frequently Asked Questions
Can Rails run in the browser? Experiments exist, with a Wasm-compatible database and no network server, but it is a demonstration rather than a production pattern.
Does require of stdlib work?
Yes, from the module’s embedded file system, for libraries included in the build.
Can Ruby run in a Web Worker? Yes — create the VM inside the worker and post results back.
What about mruby? mruby is far smaller and compiles to Wasm easily, but it is a different, embeddable dialect with a reduced standard library.
How do I show puts output on the page?
Redirect $stdout to a StringIO before running and read it afterwards, or connect the WASI shim’s stdout to a callback.
Related
- Running Python in the browser with Pyodide — the Python counterpart.
- Running WASI modules in the browser — the WASI shim underneath.
- Comparing payload size across languages — interpreters versus compiled languages.
- Lazy loading Wasm on first use — loading Ruby only when needed.
← Back to Other Languages in the Browser