Ruby on Rails on WebAssembly, the full-stack in-browser journey

Vladimir Dementyev
Vladimir Dementyev

Published: January 31, 2025

Imagine running a fully functional blog 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 classic web development and opening up exciting new possibilities. In this post, Vladimir Dementyev (Head of Backend at Evil Martians) shares the progress on making Ruby on Rails Wasm- and browser-ready:

  • 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

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 popularity of the framework began many years ago with the release of the 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, I'd like to bring this magical feeling back by creating a Rails application that runs fully in your browser. Your journey starts with creating a basic Rails application the usual way, and then packaging it for Wasm.

Background: a "blog in 15 minutes" on the command line

Assuming you have Ruby and Ruby on Rails installed on your machine, you start with 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) ===========

Without even touching the codebase, you can now 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

Now, you can open your blog at http://localhost:3000/posts and start writing posts!

A Ruby on Rails blog launched from the command line running in the browser.

You have a very bare-bone, but functional blog application built in minutes. It's a full-stack, server-controlled application: you have a database (SQLite) to keep 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. Your journey will continue in the opposite direction: instead of putting your application somewhere far away, you'll "deploy" it locally.

Next level: a "blog in 15 minutes" in Wasm

Since the addition of WebAssembly, browsers became capable of running not only JavaScript code, but any code compilable into Wasm. And Ruby is not an exception. Surely, Rails is more than Ruby, but before digging into the differences, let us continue the demo and wasmify (a verb coined by the wasmify-rails library) the Rails application!

You only need to execute a few commands to compile your blog application into a Wasm module and run it in the browser.

First, you install the wasmify-rails library using Bundler (the npm of Ruby) and run its generator using the Rails CLI:

$ bundle add wasmify-rails

$ bin/rails wasmify:install

  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, 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

This step can take some time: you must build Ruby from source to properly link native extensions (written in C) from the third-party libraries. This (temporary) drawback is covered later in the post.

The compiled Wasm module is just a foundation for your application. You must also pack the application code itself and all the assets (for example, images, CSS, JavaScript). Before doing the packing, create a basic launcher application that could be used to run the wasmified Rails in the browser. For that, there's also a generator command:

$ bin/rails wasmify:pwa

  create  pwa
  create  pwa/boot.html
  create  pwa/boot.js
  ...
  prepend  config/wasmify.yml

The previous command 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 is to pack the whole application into a single Wasm binary:

$ bin/rails wasmify:pack
...
Packed the application to pwa/app.wasm
Size: 76.2 MB

That's it! Run the launcher app and see your Rails blogging application running fully within 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, and click it—enjoy working with the Rails app running locally in your browser!

A Ruby on Rails blog launched from a browser tab running in another browser tab.

Doesn't it feel like magic running a monolithic server-side application not just on your machine but within the browser sandbox? For me (even though I'm the "sorcerer"), it still looks like a fantasy. But there is no magic involved, only the progress of technology.

Demo

You can experience the demo embedded in the article or launch the demo in a standalone window. Check out 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 post explains the components that are part of this architecture.

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. What's exciting about the "blog in 15 minutes" demo, is that this can be achieved without rewriting the application code. The same code was used to run the application in a classic, server-side mode and in the browser.

The components that make up a Ruby on Rails app: a web server, a database, a queue, and storage. Plus the core Ruby components: the gems, native extensions, system tools, and the Ruby VM.

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 the somewhat esoteric local serving needs.

The foundation: ruby.wasm

Ruby became officially Wasm-ready in 2022 (since version 3.2.0) meaning that the C source code could be compiled to Wasm and 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 ruby:wasm project also comes with the build tools that lets You 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 any extension yet, but most of them).

Currently, Ruby fully supports the WebAssembly System Interface, WASI 0.1. WASI 0.2 which includes the Component Model is already in the alpha state and a few steps from completion.Once WASI 0.2 is supported it will eliminate the current need of recompiling the whole language every time you need to add new native dependencies: they could be componentized.

As a side effect, the Component Model should also help with reducing the bundle size. You can learn more about the ruby.wasm development and progress from the What you can do with Ruby on WebAssembly talk.

So, the Ruby part of the Wasm equation is solved. But Rails as a web framework needs all of the components shown in the previous diagram. Read on to learn how to put other components into the browser and link them together in Rails.

Connect to a database running in the browser

SQLite3 comes with an official Wasm distribution and a corresponding JavaScript wrapper, therefore is ready to be embedded in-browser. PostgreSQL for Wasm is available through the PGlite project. Therefore, 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 away the actual SQL-speaking database implementation from the application code through the database adapters. Out of the box, Rails gives you SQLite3, PostgreSQL, and MySQL adapters. However, they all assume connecting 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 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), so 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—a bridge between a Rails Wasm module and a database.

For example, here is 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 lot of effort. However, if data synchronization with some central source of truth is required, then you may face a challenge of a higher level. This question is out of the scope of this post (hint: check out the Rails on PGlite and ElectricSQL demo).

Service worker as a web server

Another essential component of any web application is a web server. Users interact with web applications using 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. 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, {...}, [...]]

If you found the request format familiar, then 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. The actual implementation is too long to post here though.

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.

Keep 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. 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, no additional infrastructure components required. And there is already a ready-made plugin for that in Rails, Active Storage Database. However, serving files stored in a database through the Rails application running within WebAssembly is not ideal because it involves rounds of (de-)serialization that are not free.

A better and more browser-optimized solution would be to use File System APIs and process file uploads and server uploaded files directly from the service worker. A perfect candidate for such infrastructure is the OPFS (origin private file system), a very recent browser API that will definitely play an important role for the future in-browser applications.

What Rails and Wasm can achieve together

I'm pretty sure you've been asking yourself this question as you started reading the article: why run a server-side framework in the browser? 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. Labels shouldn't stop you from exploring new possibilities and pushing boundaries of the framework (for example, Ruby on Rails) as well as the boundaries of the runtime (WebAssembly). Both could benefit from such unconventional use cases.

There are plenty of conventional, or practical, use cases, too.

First, 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 that 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 a special case of in-browser coding 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 on reproducing your scenario. And, actually, it's already started happening in Ruby thanks to the RunRuby.dev project (here is an example issue resolved with the in-browser reproduction).

Another use case is offline-capable (or offline-aware) applications. Offline-capable applications that usually work using the network, but when there is no connection, they stay usable. For example, an email client that lets you search through your inbox while offline. Or, a music library application with the "Store on device" capability, so your favourite music keeps beating even if there is no network connection. Both examples depend on the data stored locally, not just using a cache as with classic 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.

It's just the beginning of this 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).