I built a WASM-compatible Rails playground that runs entirely in your browser. No server, no setup, just Rails. This is built on the backs of people much smarter than me.
Try it out below. The VM takes a few seconds to load, but once it’s ready you can run any Active Record code against a real SQLite database.
Post.published.map(&:title)
Post Load (0.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = 1
=> ["Getting Started with Ruby", "Rails is Magic"]The stack
The playground runs wasmify-rails, which compiles a Rails application to WebAssembly. The 76MB bundle includes Ruby 3.2 and SQLite—everything needed to run ActiveRecord queries in the browser. Each playground gets an in-memory SQLite database that lives only for the duration of your session.
The WASM bundle loads once and is shared across all playgrounds on a page. Initializing a Ruby VM is expensive—around 2-3 seconds on my ripping-fast machine, so spinning up separate VMs per playground would make the UX unusable. Instead, all playgrounds share a single VM but get isolated execution contexts and separate databases.
VM initialization
When the first playground loads, it triggers VM initialization:
export async function initVM() {
if (vm) return vm;
await initSqlite();
dbProxy = createDatabaseProxy();
registerSQLiteWasmInterface(self, dbProxy);
vm = await initRailsVM(`${window.CDN_HOST}/assets/rails-playground/app.wasm`, {
database: { adapter: "sqlite3_wasm" },
});
vm.eval("REPL_WORKSPACES = {}");
return vm;
}
The registerSQLiteWasmInterface call is where things get interesting. It wires up a JavaScript object to handle all SQLite operations from Ruby. Normally you’d pass a database instance directly, but I pass a proxy instead. More on that below.
REPL_WORKSPACES is a Ruby hash that stores isolated workspace objects. Each playground gets its own entry—a plain Object.new that serves as the evaluation context.
Per-playground database isolation
ActiveRecord assumes a single database connection. You call establish_connection once at boot and that connection handles all queries. But I wanted each playground to have its own isolated database. If you Post.create in playground A, it shouldn’t show up in playground B’s queries.
The obvious solutions don’t work well here. Running separate VMs would mean 76MB downloads and multi-second init times per playground. Using SQLite’s ATTACH DATABASE would require rewriting queries to prefix table names. Prefixing table names in the schema would break model definitions.
Rails has built-in multiple database support—you can use connected_to to switch databases at runtime. But it doesn’t help here. When you call registerSQLiteWasmInterface(self, db), you’re registering a single JavaScript object that handles all SQLite operations from Ruby. The sqlite3_wasm adapter doesn’t use the database path from connection configs to open files—it just calls methods on whatever JavaScript object was registered. The path is ignored. So even if you tried connected_to(database: { adapter: 'sqlite3_wasm', database: '/other.sqlite3' }), you’d still hit the same database. The routing has to happen on the JavaScript side because that’s where the actual SQLite instances live.
I could wrong about all this, I don’t know. But it works! So I’m going to assume that I made some better choices.
The solution I landed on: a JavaScript Proxy that intercepts all database operations and routes them to different SQLite files based on which playground is currently executing.
const databases = new Map();
let currentWorkspace = null;
export const getOrCreateDatabase = async (workspaceId) => {
await initSqlite();
if (!databases.has(workspaceId)) {
const db = new sqlite3.oo1.DB(':memory:', 'c');
databases.set(workspaceId, db);
}
return databases.get(workspaceId);
};
export const createDatabaseProxy = () => {
const handler = {
get(target, prop) {
const db = getCurrentDatabase();
if (!db) throw new Error("no active database");
const value = db[prop];
if (typeof value === 'function') {
return value.bind(db);
}
return value;
}
};
return new Proxy({}, handler);
};
The proxy looks like a normal SQLite database object to the Ruby VM. When Ruby calls any method on it—exec, prepare, whatever—the proxy looks up the current workspace’s database from the Map and forwards the call there. The Ruby side has no idea this indirection exists.
Before each operation, we switch workspaces:
async function switchToWorkspace(workspaceId) {
await getOrCreateDatabase(workspaceId);
setCurrentWorkspace(workspaceId);
vm.eval(`
ActiveRecord::Base.connection.disconnect! rescue nil
ActiveRecord::Base.establish_connection(adapter: 'sqlite3_wasm')
`);
}
The disconnect! and establish_connection cycle forces ActiveRecord to drop its cached connection and create a new one. When it does, the sqlite3_wasm adapter calls back into JavaScript to get a database handle—and now the proxy routes to the new workspace’s database file. ActiveRecord thinks it’s talking to the same database it always was. It’s not.
This pattern—using a proxy to redirect operations based on runtime context—is useful whenever you need to multiplex a single-connection abstraction across multiple backends. I’ve used similar approaches for multi-tenant database routing in production Rails apps, though there you’d typically use ActiveRecord::Base.connected_to rather than raw connection cycling. Hopefully.
Per-playground setup
Each playground can have setup code that runs before the user’s code executes. This is useful for pre-configuring state without cluttering the editor.
Schema, models, and seed data run in the global context—model classes need to be globally accessible. But setup code runs inside the workspace’s instance_eval, so any instance variables it defines are scoped to that playground. You can set @user = User.first in setup and reference @user in the editor without leaking state to other playgrounds.
Code evaluation
When you click Run, the code is wrapped and executed in the workspace context:
export async function evaluate(code, workspaceId) {
await switchToWorkspace(workspaceId);
const workspace = `REPL_WORKSPACES['${workspaceId}']`;
const wrapped = `
begin
__repl_code__ = <<~'REPL_CODE'
${code}
REPL_CODE
__repl_result__ = ${workspace}.instance_eval(__repl_code__)
if __repl_result__.respond_to?(:to_a)
{ success: true, value: __repl_result__.to_a.map(&:inspect).join("\\n") }
else
{ success: true, value: __repl_result__.inspect }
end
rescue => e
{ success: false, error: e.class.name, message: e.message }
end
`;
const result = vm.eval(wrapped);
return result.toJS();
}
The code goes into a heredoc for the same injection-prevention reasons as setup. Then it’s instance_eval‘d on the workspace object. This means self inside the user’s code is that workspace object, and any instance variables are scoped to it. @post in playground A and @post in playground B are completely separate.
The result handling has a special case for objects that respond to to_a—typically ActiveRecord relations. Rather than inspecting the relation object itself (which would show something like #<ActiveRecord::Relation [...]>), we convert to an array and inspect each record on its own line. This makes the output much more readable when you’re querying multiple records.
The noscript problem
The WASM approach works, but it has a fatal flaw: the playgrounds are invisible until JavaScript loads and executes. Disable JS, and you see nothing. This is bad for accessibility, bad for SEO, and bad for the subset of readers who browse with JS disabled.
The obvious fix is server-side rendering. But “server-side” for a static Jekyll site means build-time. I needed to run Rails code during jekyll build and inject the output into the HTML.
This turned a client-side-only feature into a progressive enhancement. With JS enabled, you get the full interactive experience. Without JS, you still see the code and its output—you just can’t modify and re-run it.
Build-time Rails execution
The build pipeline now includes a persistent Rails server that evaluates playground code during Jekyll’s render phase. Here’s why “persistent” matters: Rails takes several seconds to boot. If every playground block spawned a fresh Rails process, a post with 10 playgrounds would add minutes to build time.
Instead, a long-running Ruby process boots Rails once and handles requests over stdin/stdout:
require 'rails/all'
ENV['DATABASE_URL'] = 'sqlite3::memory:'
class App < Rails::Application
config.eager_load = false
end
App.initialize!
STDOUT.sync = true
puts "READY"
STDIN.each_line do |line|
break if line.strip == "EXIT"
config = JSON.parse(line)
result = run_snippet(config)
puts JSON.generate(result)
puts "DONE"
end
The Jekyll plugin spawns this process once, sends each playground’s config as JSON, and reads back the results. The server stays warm across all posts in the build.
Isolated execution contexts
Different blog posts define different schemas. A post about counter caches has different tables than a post about polymorphic associations. The build server needs to handle this without cross-contamination.
Each unique combination of schema, models, and seed data gets its own execution context:
$current_config_key = nil
def run_snippet(config)
config_key = [config['schema'], config['models'], config['seed']].hash
if $current_config_key != config_key
# Tear down previous context
ActiveRecord::Base.connection.tables.each do |table|
ActiveRecord::Base.connection.drop_table(table, if_exists: true)
end
# Remove old model classes
$user_constants.each do |const|
Object.send(:remove_const, const) if Object.const_defined?(const)
end
# Build new context
ActiveRecord::Schema.define { eval(config['schema']) }
eval(config['models'])
eval(config['seed'])
$user_constants = Object.constants - $baseline_constants
$current_config_key = config_key
end
# Execute the playground code
eval(config['code'])
end
When the config changes, we drop all tables, remove all user-defined constants (model classes, modules, anything the previous context defined), and rebuild from scratch. When the config matches, we skip setup entirely—playgrounds within the same post share their schema and just run code.
This brought build time for a post with 10 playgrounds from around 17 seconds to under 2 seconds. The first playground pays the setup cost; the rest are nearly instant.
Capturing interleaved output
Rails playground output has two streams: explicit output from puts statements, and implicit output from SQL query logging. These need to be interleaved correctly—if your code does puts "before", runs a query, then puts "after", the SQL should appear between them.
The WASM runtime handles this naturally because everything writes to the same output buffer. The build server needed more work.
First, we capture stdout to a tempfile during code execution:
tempfile = Tempfile.new('stdout')
original_stdout = STDOUT.dup
STDOUT.reopen(tempfile)
$capturing_sql = true
result = eval(config['code'])
$capturing_sql = false
STDOUT.reopen(original_stdout)
output = tempfile.read
Then we subscribe to ActiveRecord’s SQL notifications and write directly to the captured stdout:
ActiveSupport::Notifications.subscribe('sql.active_record') do |name, start, finish, id, payload|
next unless $capturing_sql
next if payload[:name] == "SCHEMA"
# Interpolate bind parameters into the SQL
sql = payload[:sql]
type_casted = payload[:type_casted_binds] || []
type_casted.each do |value|
sql = sql.sub('?', ActiveRecord::Base.connection.quote(value))
end
duration = ((finish - start) * 1000).round(1)
$stdout.puts "\e[1m\e[36m#{payload[:name]} (#{duration}ms)\e[0m \e[1m\e[34m#{sql}\e[0m"
end
Because the notification handler writes to $stdout (which is redirected to the tempfile), SQL logs appear inline with puts output in the correct order.
ANSI to HTML conversion
The SQL logs use ANSI escape codes for colorization: cyan for the query name, blue for the SQL. The WASM runtime converts these to HTML spans with inline styles. The build server output needs to match exactly, or you’d see a visual flash when JS hydrates the page.
The conversion handles nested escape sequences and tracks open spans to ensure valid HTML:
ANSI_COLORS = {
'30' => '#000', '31' => '#f14c4c', '32' => '#89d185', '33' => '#dcdcaa',
'34' => '#569cd6', '35' => '#c586c0', '36' => '#4ec9b0', '37' => '#d4d4d4'
}.freeze
def ansi_to_html(str)
escaped = str.gsub('&', '&').gsub('<', '<').gsub('>', '>')
out = String.new
open_spans = 0
scanner = StringScanner.new(escaped)
until scanner.eos?
if scanner.scan(/\e\[([0-9;]+)m/)
codes = scanner[1].split(';')
if codes.include?('0')
out << '</span>' if open_spans > 0
open_spans = [open_spans - 1, 0].max
else
styles = []
codes.each do |c|
styles << 'font-weight:700' if c == '1'
styles << "color:#{ANSI_COLORS[c]}" if ANSI_COLORS[c]
end
if styles.any?
open_spans += 1
out << %(<span style="#{styles.join(';')}">)
end
end
else
out << scanner.getch
end
end
out << ('</span>' * open_spans)
out
end
The WASM runtime uses separate ANSI codes for bold and color (\e[1m\e[36m), so the build server does too. Using combined codes (\e[1;36m) would produce different HTML structure—functionally equivalent but visually detectable during hydration.
Progressive enhancement
The Jekyll plugin renders the full playground UI server-side: the code editor, the Run/Reset buttons, the output panel. JavaScript isn’t needed to display anything.
<<~HTML
<div class="playground" data-runtime="rails">
<div class="playground-toolbar" hidden>...</div>
<div class="language-ruby highlighter-rouge">#{highlighted_code}</div>
<div class="playground-controls">
<button class="playground-run" disabled>Run</button>
<button class="playground-reset" disabled>Reset</button>
</div>
<div class="playground-output"><pre><code>#{ansi_to_html(output)}</code></pre></div>
</div>
HTML
When JS loads, it checks if the controls already exist before injecting them:
function injectPlaygroundControls(el, runtime) {
if (el.querySelector('.playground-controls')) {
return true; // Already rendered server-side
}
// ... inject controls dynamically
}
For the output, JS checks if the HTML differs from plain text. If it does, the content was pre-rendered and doesn’t need transformation:
if (output && output.textContent.trim()) {
if (output.innerHTML === output.textContent) {
output.innerHTML = transformer(output.textContent);
}
}
Users with JS disabled see the playground with pre-computed output. Users with JS enabled can modify the code and re-run it. Same HTML serves both.
Noscript styling
Interactive controls are hidden when JS is disabled:
<noscript>
<style>
.playground-controls,
.playground-toolbar,
.code-editor-input { display: none !important; }
</style>
</noscript>
The output panel stays visible. The textarea overlay on the code editor hides, leaving just the syntax-highlighted code (which is still selectable and copyable).
Limitations
You can’t make HTTP requests or call external APIs from Ruby code. The WASM sandbox prevents it.
Gems with C extensions won’t compile to WASM. Pure Ruby gems work fine.
The full Ruby/Rails runtime is 76MB. It’s cached after the first load, but the initial download is noticeable on slow connections.
Build-time rendering adds complexity. The Jekyll plugin, Rails server script, and ANSI conversion all need to stay in sync with the client-side runtime. When I change how the WASM runtime formats output, I need to update the build pipeline to match.
For interactive demos and tutorials, these tradeoffs are worth it. You get real ActiveRecord with associations, scopes, validations, callbacks—everything you’d use in a typical Rails app. And now it works without JavaScript too.