Ruby on Rails on WebAssembly: a guide to full-stack in‑browser action

Editor’s note: the original version of this article appeared on web.dev.
Imagine a fully functional blog running in your browser—not just the frontend, but the backend, too! No servers or clouds involved—just you, your browser, and… WebAssembly! By allowing server-side frameworks to run locally, WebAssembly is blurring the boundaries of traditional web development and opening up exciting new possibilities. In this post, I’ll share the progress on making Ruby on Rails Wasm-ready and browser-ready.
Here’s what is in store:
- How to bring Rails into the browser in 15 minutes
- Behind the scenes of Rails wasmification
- Future of Rails and Wasm
Ruby on Rails’ famous “blog in 15 minutes”… now running right in your browser
Software engineering shares many similarities with art, and just as interpreting meaning depends on the audience and can diverge from the creator’s original intent, developers tend to demonstrate a kind of interpretive flexibility by repurposing tech tools in ways far beyond their intended design.

Irina Nazarova CEO at Evil Martians
Take, for example, WebAssembly. Originally introduced for bringing high-performant and secure features into the web, Wasm now stands as one of the building blocks for cloud computing and embedded systems. Sure, the browser is here to stay in addition to the traditional WebAssembly runtime. However, the way we can leverage this runtime today can be quite non-canonical.
For example, we can embed a typical server-side application right into the browser, thus eliminating the distance between the frontends and backends of a typical web application. Let’s jump in.
Ruby on Rails is a web framework focused on developer productivity and shipping things fast. It’s the technology used by industry leaders such as GitHub and Shopify.
The framework began its rise to popularity many years ago with the release of the now-famous “How to build a blog in 15 minutes” video published by David Heinemeier Hansson (or DHH). Back in 2005, it was unimaginable to build a fully working web application in such a short time. It felt like magic!
Today, we’re going to try to bring some of that magic feeling back, and our journey will begin by creating a basic Rails application the usual way, then packaging it for Wasm.
Background: a “blog in 15 minutes” on the command line
We’ll assume you already have Ruby and Ruby on Rails installed on your machine. From there, let’s start by creating a new Ruby on Rails application and scaffolding some functionality (just like in the original “blog in 15 minutes” video):
$ rails new --css=tailwind web_dev_blog
create .ruby-version
...
$ cd web_dev_blog
$ bin/rails generate scaffold Post title:string date:date body:text
create db/migrate/20241217183624_create_posts.rb
create app/models/post.rb
...
$ bin/rails db:migrate
== 20241217183624 CreatePosts: migrating ====================
-- create_table(:posts)
-> 0.0017s
== 20241217183624 CreatePosts: migrated (0.0018s) ===========
Now, without even touching the codebase, you can run the application and see it in action:
$ bin/dev
=> Booting Puma
=> Rails 8.0.1 application starting in development
...
* Listening on http://127.0.0.1:3000
You can open your blog at http://localhost:3000/posts and start writing posts!

So, although this is a very bare-bone setup, it’s nevertheless a fully functional blog application, and we built in just minutes. This is also a full-stack, server-controlled application: you have a database (SQLite) to deal with your data, a web server to handle HTTP requests (Puma), and a Ruby program to keep your business logic, provide UI, and process user interactions. Finally, there is a thin layer of JavaScript (Turbo) to streamline the browsing experience.
The official Rails demo continues in the direction of deploying this application onto a bare metal server and, thus, making it production-ready. But our journey will move along in the opposite direction: instead of putting your application somewhere off far away in the ether, you’ll “deploy” it locally.
Next level: a “blog in 15 minutes” in Wasm
Since the addition of WebAssembly, our browsers have become capable of running not only JavaScript code, but any code compilable into Wasm. Ruby is no exception. Surely, Rails is more than Ruby, but before digging into the differences, let’s continue the demo and wasmify (a verb coined by the wasmify-rails library) the Rails app!
To do this, you’ll only need to execute a few commands to compile your blog application into a Wasm module and run it in the browser.
First, install the wasmify-rails library using Bundler (the npm
of Ruby) and run its generator via the Rails CLI:
$ bundle add wasmify-rails
$ bin/rails wasmify:rails
create config/wasmify.yml
create config/environments/wasm.rb
...
info ✅ The application is prepared for Wasm-ificaiton!
The wasmify:rails
command configures a dedicated “wasm” execution environment (in addition to the default “development”, “test”, and “production” environments) and installs the required dependencies. (For a greenfield Rails application, this is enough to make it Wasm-ready.)
Next, we’ll build the core Wasm module containing the Ruby runtime, the standard library, and all the application dependencies:
$ bin/rails wasmify:build
==> RubyWasm::BuildSource(3.3) -- Building
...
==> RubyWasm::CrossRubyProduct(ruby-3.3-wasm32-unknown-wasip1-full-4aaed4fbda7afe0bdf4e22167afd101e) -- done in 47.37s
INFO: Packaging gem: rake-13.2.1
...
INFO: Packaging gem: wasmify-rails-0.2.0
INFO: Packaging setup.rb: bundle/setup.rb
INFO: Size: 73.77 MB
Let’s note that this step can take some time as you must build Ruby from source to properly link the native extensions (written in C) from the third-party libraries. This (temporary) drawback of the process is detailed later in the post.
Now, the compiled Wasm module is just the foundation for your application. You must also pack the application code itself and all the assets (for example, images, CSS, JavaScript). Before doing that, we’ll create a simple launcher application that could be used to run the wasmified Rails in the browser. For this, there’s also a generator command, as seen here:
$ bin/rails wasmify:pwa
create pwa
create pwa/boot.html
create pwa/boot.js
...
prepend config/wasmify.yml
The command above generates a minimal PWA application built with Vite that can be used locally to test the compiled Rails Wasm module or be deployed statically to distribute the app.
Now, with the launcher, all you need to do is to pack the entire application into a single Wasm binary:
$ bin/rails wasmify:pack
...
Packed the application to pwa/app.wasm
Size: 76.2 MB
…and that’s it! Next, run the launcher app and you’ll see your Rails blogging app running entirely in the browser:
$ cd pwa/
$ yarn dev
VITE v4.5.5 ready in 290 ms
➜ Local: http://localhost:5173/
Go to http://localhost:5173, wait a bit for the “Launch” button to become active, then click. And with that click, enjoy working with the Rails app running locally in your browser!

Doesn’t it feel like magic to be running a monolithic server-side application, not just on your machine, but within the browser sandbox itself? For me (even though I’m the “sorcerer”), it still seems like some kind of fantasy. However, there is no magic involved, only the technological progress!
Demo
You can try the demo embedded in this article or launch the demo in a standalone window. Also, be sure to have a look at the source code on GitHub.
Behind the scenes of Rails on Wasm
To better understand the challenges (and solutions) of packing a server-side application into a Wasm module, the rest of this article will explain the components that make up this architecture.
Of course, a web application depends on many more things than just a programming language used to write the application code. Each component must also be brought to your local deployment environment—the browser. The exciting part of the “blog in 15 minutes” demo is that this can be achieved without rewriting the application code; the same code used to run the application in the classic, server-side mode was used in the browser.

A framework like Ruby on Rails gives you an interface, an abstraction to communicate with infrastructure components. The following section discusses how you can employ the framework architecture to serve your somewhat esoteric local serving needs.
The foundation: ruby.wasm
Ruby became officially Wasm-ready in 2022 (since version 3.2.0) and this meant that the C source code could be compiled to Wasm in order to bring a Ruby VM anywhere you want.
The ruby.wasm project ships precompiled modules and JavaScript bindings to run Ruby in the browser (or any other JavaScript runtime).
The project also includes the build tools that allows you to easily build a custom Ruby version with additional dependencies—this is very important for projects relying on libraries with C extensions. Yes, you can compile native extensions into Wasm, too! (Well, not yet any extension, but most of them).
At present, Ruby fully supports the WebAssembly System Interface, WASI 0.1. WASI 0.2 which includes the Component Model and is already in the alpha state with just a few steps from completion. Once WASI 0.2 is supported, it will eliminate the current need to recompile the entire language every time you need to add new native dependencies as they can be componentized instead. As a side effect, the Component Model should also help with reducing the overall bundle size.
You can learn more about the ruby.wasm development and progress from this talk: What you can do with Ruby on WebAssembly.
So, the Ruby part of the Wasm equation has been solved. But Rails as a web framework still needs all of the components shown in the previous diagram. So, read on to learn how to put other components into the browser and link them together in Rails.
Connecting to a database running in the browser
SQLite3 comes with an official Wasm distribution and a corresponding JavaScript wrapper, so it’s ready to be embedded in-browser.
PostgreSQL for Wasm is available through the PGlite project, and this means you only need to figure out how to connect to the in-browser database from the Rails on Wasm application.
A component, or sub-framework, of Rails responsible for data modeling and database interactions is called Active Record (yes, named after the ORM design pattern). Active Record abstracts the actual SQL-speaking database implementation away from the application code via the database adapters.
Out of the box, Rails gives you SQLite3, PostgreSQL, and MySQL adapters. However, they all assume a connection to real databases available over the network. To overcome this, you can write your own adapters to connect to local, in-browser databases!
This is how the SQLite3 Wasm and PGlite adapters (implemented as a part of the Wasmify Rails project) are created:
- The adapter class inherits from the corresponding built-in adapter (for example, class
PGliteAdapter < PostgreSQLAdapter
), meaning you can re-use the actual query preparation and results parsing logic. - Instead of the low-level database connection, you use an external interface object that lives in the JavaScript runtime—this is a bridge between the Rails Wasm module and the database.
For instance, here’s the bridge implementation for SQLite3 Wasm:
export function registerSQLiteWasmInterface(worker, db, opts = {}) {
const name = opts.name || "sqliteForRails";
worker[name] = {
exec: function (sql) {
let cols = [];
let rows = db.exec(sql, { columnNames: cols, returnValue: "resultRows" });
return {
cols,
rows,
};
},
changes: function () {
return db.changes();
},
};
}
From the application perspective, the shift from a “real” database to an in-browser one is just a matter of configuration:
# config/database.yml
development:
adapter: sqlite3
production:
adapter: sqlite3
wasm:
adapter: sqlite3_wasm
js_interface: "sqliteForRails"
Working with a local database doesn’t require a ton of effort. However, if data synchronization with some central source of truth is required, then you may face a high-level challenge. That said, this question is beyond the scope of this article (hint: check out the Rails on PGlite and ElectricSQL demo).
Service workers as web servers
Another essential component of any web application is the web server, and users interact with web applications via HTTP requests. Thus, you need a way to route HTTP requests triggered by navigation or form submissions to your Wasm module. Luckily, the browser has an answer for that: service workers.
A service worker is a special kind of a Web Worker that acts as a proxy between the JavaScript application and the network. It can intercept requests and manipulate them, for example: serve cached data, redirect to other URLs or… to Wasm modules! Here is a sketch of a service working serving requests using a Rails application running in Wasm:
// The vm variable holds a reference to the Wasm module with a
// Ruby VM initialized
let vm;
// The db variable holds a reference to the in-browser
// database interface
let db;
const initVM = async (progress, opts = {}) => {
if (vm) return vm;
if (!db) {
await initDB(progress);
}
vm = await initRailsVM("/app.wasm");
return vm;
};
const rackHandler = new RackHandler(initVM});
self.addEventListener("fetch", (event) => {
// ...
return event.respondWith(
rackHandler.handle(event.request)
);
});
The “fetch” is triggered every time a request is made by the browser. You can obtain the request information (URL, HTTP headers, body) and construct your own request object.
Rails, like most Ruby web applications, relies on the Rack interface for working with HTTP requests. The Rack interface describes the format of the request and response objects as well as the interface of the underlying HTTP handler (application). You can express these properties as follows:
request = {
"REQUEST_METHOD" => "GET",
"SCRIPT_NAME" => "",
"SERVER_NAME" => "localhost",
"SERVER_PORT" => "3000",
"PATH_INFO" => "/posts"
}
handler = proc do |env|
[
200,
{"Content-Type" => "text/html"},
["<!doctype html><html><body>Hello Web!</body></html>"]
]
end
handler.call(request) #=> [200, {...}, [...]]
By the way, if this request format seems familiar, you’ve probably worked with CGI back in the days.
The RackHandler JavaScript object is responsible for converting requests and responses between JavaScript and Ruby realms. Given that Rack is used by most Ruby web applications, the implementation becomes universal, not Rails-specific. (However, the actual implementation is too lengthy to post here.)
A service worker is one of the key, integral points of an in-browser web application; it’s not only an HTTP proxy, but also a caching layer and a network switcher (that is, you can build a local-first or offline-capable application). This is also a component that can help you serve user-uploaded files.
Keeping file uploads in the browser
One of the first additional features to implement in your fresh blog application is likely to be support for file uploads, or more specifically, attaching images to posts. To achieve this, you need a way to store and serve files.
In Rails, the part of the framework responsible for dealing with file uploads is called Active Storage. Active Storage gives developers abstractions and interfaces to work with files without thinking about the low-level storage mechanism. No matter where you store your files, on a hard drive or in the cloud, the application code stays unaware of it.
Similarly to Active Record, in order to support a custom storage mechanism, all you need is to implement a corresponding storage service adapter. But where to store files in the browser?
The traditional option is to use a database. Yes, you can store files as blobs in the database with no additional infrastructure components required. Further, there is already a ready-made Rails plugin for this: Active Storage Database. That said, serving files stored in a database through the Rails application running within WebAssembly isn’t ideal since it involves rounds of (de-)serialization, which are not free.
A better and more browser-optimized solution would be to use File System APIs; this would involve processing file uploads and server uploaded files directly from the service worker. A perfect candidate for this kind of infrastructure is the OPFS (origin private file system), which is a very recent browser API that will definitely play an important role for future in-browser applications.
What Rails and Wasm can achieve together
I’m fairly sure you’ve probably been asking yourself the next question as you started reading the article: why run a server-side framework in the browser?
Well, the idea of a framework or a library being server-side (or client-side) is just a label. Good code (and especially, a good abstraction) works everywhere. These labels shouldn’t stop us from exploring new possibilities and pushing the boundaries of a framework (in this case, Ruby on Rails) as well as the boundaries of the runtime (WebAssembly). Both could benefit from such unconventional use cases.
But there are also plenty of conventional, practical, use cases, too.
First of all, bringing the framework to the browser opens enormous learning and prototyping opportunities. Imagine being able to play with libraries, plugins, and patterns right in your browser and together with other people. Stackblitz made this possible for JavaScript frameworks. Another example is a WordPress Playground which made it possible to play with WordPress themes without leaving the web page. Wasm could enable something similar for Ruby and its ecosystem.
There’s also a special case of in-browser coding which is especially useful to open source developers—triaging and debugging issues. Again, StackBlitz made this a thing for JavaScript projects: you create a minimal reproduction script, point at the link in a GitHub Issue, and spare maintainers the time of reproducing your scenario. And, actually, this has already started happening in Ruby thanks to the RunRuby.dev project (here’s an example issue resolved with the in-browser reproduction).
Another use case is offline-capable (or offline-aware) applications. Offline-capable applications usually work using the network, but when there is no connection, they stay usable. For example, this might be an email client that allows you to search through your inbox while offline. Another example could be a music library app with a “Store on device” capability, so your favourite music keeps beating even without a network connection. Both examples depend on the data stored locally, not just using a cache as with traditional PWAs.
Finally, building local (or desktop) applications with Rails also makes sense, because the productivity the framework gives you doesn’t depend on the runtime. Full-featured frameworks suit well for building personal data- and logic-heavy applications. And using Wasm as a portable distribution format is also a viable option.
This is just the beginning of the Rails on Wasm journey. You can learn more about the challenges and solutions in the Ruby on Rails on WebAssembly ebook …which, by the way, is an offline-capable Rails application itself!