LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Ruby: The Language That Still Ships

rubyrailsperformanceweb-developmentdeploymenthotwire

Ruby has been declared dead so many times that the community stopped counting. Node.js was going to kill it. Go was going to kill it. Python’s explosion in ML was going to kill it. The shift to microservices was going to kill it.

Shopify still runs the largest Rails app in the world. GitHub deploys a two-million-line Rails monolith twenty times a day. Stripe powers global fintech on Ruby. Basecamp is still building product on the framework it created. The language that was supposed to die keeps shipping.

What’s less well understood is that Ruby and Rails have changed substantially. YJIT—a JIT compiler written in Rust—has made Ruby 3.4 roughly twice as fast as Ruby 2.7 on real workloads. Rails 8 ships with a deployment tool, a job queue, a cache, and a WebSocket layer that require no Redis, no managed queues, and no cloud vendor. The economics of 2026—where profitable, lean SaaS companies matter more than hypergrowth unicorns—happen to favor exactly what Ruby and Rails have always been good at.


Who Still Uses Ruby in 2026

The companies running Ruby in production are not startups trying to ship fast and hoping to rewrite later. They’re mature enterprises who’ve evaluated the alternatives and stayed:

Shopify hosts millions of merchants, processes trillions in GMV annually, and describes itself as “the biggest Rails app in the world.” Their engineering team wrote YJIT.

GitHub runs a Rails monolith with over 2 million lines of code, 1,000+ engineers contributing to it, deployed 20 times per day. They’ve talked publicly about the cost of rewrites and chose to keep Rails.

Stripe powers global payment infrastructure with Ruby. Their public API client libraries and internal services run on it.

Basecamp created Rails and continues to build Basecamp and HEY on it. DHH and the 37signals team are actively pushing Rails forward with Rails 8 and Kamal.

The pattern: these are all profitable, engineering-led companies with the resources to switch if they wanted to. They haven’t because Ruby and Rails remain genuinely productive.


Performance: YJIT Changed the Story

Ruby’s reputation for slowness was earned. MRI Ruby (the reference implementation) has a Global VM Lock that prevents true parallelism, and historically its raw execution speed lagged behind compiled languages and JVM-based alternatives.

YJIT (Yet Another JIT) changed the baseline. It’s an in-process JIT compiler written in Rust, integrated into MRI Ruby starting in 3.1 and production-ready since 3.2. The Shopify team wrote it specifically to address their latency and throughput needs.

On Shopify’s production benchmark suite (x86-64), YJIT 3.4 is approximately 92% faster than the Ruby interpreter. On typical web application workloads—the kind Rails apps actually run—expect 40-70% throughput improvements over Ruby 2.7 with no code changes.

1
2
3
4
5
6
7
8
9
# Enable YJIT — it's on by default in Ruby 3.3+
# For older versions:
ruby --yjit app.rb

# Or in config/environments/production.rb
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)

# Check YJIT stats
RubyVM::YJIT.runtime_stats

YJIT 3.4 improvements over 3.3:

  • Better method inlining (both Ruby and C methods)
  • Megamorphic call site optimization
  • 5-7% faster than 3.3.6 on the same benchmarks
  • Slightly lower memory usage despite compiling more code

Ruby is not Go or Java in raw throughput. But for web applications that spend most of their time waiting on database queries and network I/O, raw throughput is rarely the binding constraint. The question is whether Ruby is fast enough—and for most web workloads, it is.


Concurrency: Fibers, Ractors, and the GVL

Ruby’s concurrency story has three distinct layers with different maturity levels.

Fibers and the Fiber::Scheduler

Fibers are lightweight coroutines—cooperative green threads that yield control explicitly. The Fiber::Scheduler interface (Ruby 3.0+) allows blocking I/O operations to be transparently redirected to an event loop:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# With a scheduler set, blocking I/O automatically yields to other fibers
require "async"

Async do
  # These run concurrently — each yields during I/O
  responses = 10.times.map do |i|
    Async do
      HTTP.get("https://api.example.com/items/#{i}")
    end
  end

  responses.each { |r| puts r.wait.body }
end

The Falcon web server uses this model. Where Puma uses OS threads (each costing ~1MB of stack), Falcon uses fibers (~4KB each). A single process can handle thousands of concurrent connections. The constraint: libraries must be fiber-aware (non-blocking). The async gem ecosystem has matured to cover most common operations.

For new applications where raw concurrency matters, Falcon + async is worth evaluating. For existing applications on Puma, the threading model works fine—virtual threads aren’t Ruby’s answer yet, but Fibers cover the async I/O case effectively.

Threads

Ruby threads exist and work, but the GVL means only one thread executes Ruby code at a time. They’re useful for I/O-bound concurrency (multiple database queries in parallel) but don’t provide CPU parallelism.

1
2
3
4
5
threads = 5.times.map do |i|
  Thread.new { database.query("SELECT * FROM table_#{i}") }
end
threads.map(&:join)
# These run concurrently because I/O releases the GVL

Ractors (Experimental)

Ractors provide true parallelism by giving each Ractor its own GVL. They’re the answer to “can Ruby use multiple CPU cores?”

1
2
3
4
5
6
7
8
9
# CPU-bound work in parallel
workers = 4.times.map do |i|
  Ractor.new(i) do |chunk_id|
    # Runs in parallel — true CPU parallelism
    process_chunk(data[chunk_id])
  end
end

results = workers.map(&:take)

The catch: Ractors enforce strict object isolation. Objects must be explicitly sent between Ractors via message passing. Mutable shared state causes errors. Most existing Ruby gems are not Ractor-safe.

Ractors remain experimental in Ruby 3.4. They’re genuinely useful for CPU-intensive background jobs where you control the data flow, but they’re not a drop-in concurrency solution for general application code. Think of them as a tool for specific high-CPU tasks, not a replacement for the threading model.


Optional Type Safety with RBS

Ruby is dynamically typed, but Ruby 3+ ships with RBS—a type signature language for describing Ruby programs. Type signatures live in .rbs files, leaving source code unchanged:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# user.rb — untouched Ruby source
class User
  attr_accessor :email, :name
  attr_reader :created_at

  def self.create(email:, password:)
    # ...
  end

  def authenticate(password)
    # ...
  end
end
# sig/user.rbs — separate type signatures
class User
  attr_accessor email: String
  attr_accessor name: String
  attr_reader created_at: Time

  def self.create: (email: String, password: String) -> User
  def authenticate: (password: String) -> bool
end

Run type checking with Steep:

1
2
3
gem install steep
steep check
# Verifies all call sites match the signatures

The main trade-off versus Sorbet (the other popular Ruby type checker):

RBS + Steep Sorbet
Type location Separate .rbs files Inline annotations in source
Source code impact None Requires # typed: true headers and T. annotations
Adoption Gradual, file-by-file More invasive
Ecosystem Bundled with Ruby Larger existing gem support
Style Like TypeScript’s .d.ts Like Python type hints

For new projects: RBS + Steep is the official path. For projects that need deep type coverage quickly and have the team buy-in to annotate source: Sorbet. Both are production-ready in 2026—the “experimental” label is gone.

Neither matches TypeScript’s breadth of ecosystem signatures, but coverage improves every year as gem maintainers add RBS signatures.


Rails 8: The “No PaaS Required” Release

Rails 8 (November 2024) is built around a specific philosophy: you should be able to run a serious production application on a single server with no cloud vendors and no managed services. It ships the tooling to make that real.

The Solid Stack

Rails 8 replaces three common Redis dependencies with database-backed alternatives:

Solid Queue replaces background job queues (Sidekiq, Resque):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# config/application.rb
config.active_job.queue_adapter = :solid_queue

# A job looks the same as any Active Job
class ProcessOrderJob < ApplicationJob
  queue_as :default

  def perform(order_id)
    Order.find(order_id).process!
  end
end

# Enqueue as normal
ProcessOrderJob.perform_later(order.id)

Solid Queue uses FOR UPDATE SKIP LOCKED—a SQL feature in PostgreSQL, MySQL 8+, and SQLite—to efficiently poll for jobs without distributed locking. No Redis, no Sidekiq process, just your database.

Solid Cache replaces Redis-backed caching:

1
2
3
4
5
6
7
# config/environments/production.rb
config.cache_store = :solid_cache_store

# Usage is identical to any Rails cache
Rails.cache.fetch("user/#{id}/dashboard", expires_in: 5.minutes) do
  User.find(id).build_dashboard
end

For most applications, the database is already on fast local storage. Solid Cache can be faster than Redis when Redis is a network hop away.

Solid Cable replaces Redis-backed Action Cable (WebSockets):

1
2
3
4
# config/cable.yml
production:
  adapter: solid_cable
  polling_interval: 0.1.seconds

Real-time features via Turbo Streams now work without Redis. The polling interval is configurable based on your latency requirements.

Authentication Generator

1
bin/rails generate authentication

Generates a complete authentication system:

  • User model with has_secure_password
  • Session model tracking IP, user agent, and expiration
  • SessionsController for sign-in/sign-out
  • PasswordResetsController for email-based password recovery
  • Before-action helpers for requiring authentication
1
2
3
4
5
6
7
8
9
class ApplicationController < ActionController::Base
  before_action :require_authentication

  private

  def require_authentication
    resume_session || request_authentication
  end
end

No Devise dependency for the common case. Roll in gems for OAuth, two-factor, and other extensions as needed.

SQLite in Production

Rails 8 defaults to SQLite for new applications and makes the case that SQLite is appropriate for production:

1
rails new myapp  # SQLite by default

What makes this work:

  • WAL mode enabled automatically (Write-Ahead Logging allows concurrent readers with one writer)
  • Litestream for continuous replication to S3 (point-in-time recovery)
  • Single-server deployment required (SQLite is file-based, not network-accessible)
  • Surprising performance: reads from SQLite on local NVMe are often faster than PostgreSQL over a network

The constraint is real: once you need multiple application servers, you need PostgreSQL or MySQL. But for a single-server deployment handling thousands of daily requests, SQLite eliminates the database server entirely.


Kamal: Deployment Without Kubernetes

Kamal (formerly MRSK) is the deployment tool built by 37signals and included in Rails 8 by default. It takes a bare Linux server and deploys your Docker containers with a single command:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# config/deploy.yml
service: myapp
image: myorg/myapp

servers:
  web:
    hosts:
      - 203.0.113.10
    options:
      memory: "1g"
  workers:
    hosts:
      - 203.0.113.10
    cmd: bundle exec rake jobs:work

proxy:
  ssl: true
  hosts:
    - myapp.com

registry:
  server: ghcr.io
  username: myorg
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  secret:
    - DATABASE_URL
    - SECRET_KEY_BASE
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# First-time setup — provisions and deploys
kamal setup

# Deploy a new version
kamal deploy

# Zero-downtime rollout
kamal deploy --rolling

# Roll back
kamal rollback

Kamal Proxy (the bundled reverse proxy, replacing Traefik) handles TLS via Let’s Encrypt, health checks, and rolling restarts with zero downtime.

The complete stack for a new Rails 8 app:

  • Hetzner or Linode VPS (~$20/month for 4 vCPU / 8GB)
  • Kamal for deployment
  • Solid Queue + Solid Cache + Solid Cable (all database-backed)
  • SQLite with Litestream for persistence
  • Kamal Proxy for TLS and routing

No Heroku, no AWS, no Redis, no managed databases. This is a legitimate production stack for applications handling thousands of daily users.


Hotwire: Interactive UIs Without React

Hotwire (Turbo + Stimulus) is Rails’ answer to the JavaScript framework problem. The premise: most interactivity in web applications can be achieved by streaming HTML fragments from the server rather than building a JavaScript SPA.

Turbo Drive

Intercepts link clicks and form submissions, replaces page content without a full reload. Zero configuration—it’s on by default.

Turbo Frames

Scope a section of the page for independent navigation and form submission:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- The frame updates independently when its form submits -->
<turbo-frame id="search-results">
  <%= form_with url: search_path, method: :get do |f| %>
    <%= f.text_field :q, data: { turbo_frame: "search-results" } %>
    <%= f.submit "Search" %>
  <% end %>

  <% @results.each do |result| %>
    <div><%= result.name %></div>
  <% end %>
</turbo-frame>

The controller returns only the frame content on subsequent requests—no full page reload, no JavaScript written.

Turbo Streams

Broadcast real-time HTML updates over WebSockets:

1
2
3
4
5
6
7
# In a model callback or job
after_create_commit do
  broadcast_prepend_to "comments",
    target: "comments-list",
    partial: "comments/comment",
    locals: { comment: self }
end
1
2
3
4
5
<!-- The list updates for all connected users when a comment is created -->
<div id="comments-list">
  <%= turbo_stream_from "comments" %>
  <%= render @comments %>
</div>

New comments appear instantly for all viewers, no polling, no JavaScript event handlers.

Stimulus

Minimal JavaScript behavior bound via HTML attributes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// controllers/toggle_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["content"]

  toggle() {
    this.contentTarget.classList.toggle("hidden")
  }
}
1
2
3
4
5
6
<div data-controller="toggle">
  <button data-action="click->toggle#toggle">Show/Hide</button>
  <div data-toggle-target="content" class="hidden">
    Hidden content
  </div>
</div>

No component lifecycle, no state management, no build step complexity. Stimulus adds behavior to HTML rather than generating it.

The honest trade-off: Hotwire excels for CRUD applications, dashboards, and content-heavy sites. For highly interactive UIs with complex client-side state (draggable kanban boards, collaborative editors, complex form wizards), React or Vue may still be warranted. But a surprising amount of “SPA-required” functionality is actually achievable with Turbo Frames and a little Stimulus.


Modern Ruby Patterns

Pattern Matching

Pattern matching (stable since Ruby 3.1) provides expressive data destructuring:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Match against API responses
case response.parsed_body
in { status: "success", data: { user_id:, email: } }
  User.find_or_create_by(external_id: user_id) do |u|
    u.email = email
  end
in { status: "error", code: Integer => code } if code >= 500
  raise ServiceUnavailableError, "Upstream error: #{code}"
in { status: "error", message: }
  Rails.logger.warn("API error: #{message}")
  nil
end

Pattern matching integrates naturally with Rails because API responses and database records are typically hashes. The in form raises NoMatchingPatternError if nothing matches; use case/in for exhaustive matching.

Endless Methods

1
2
3
4
# Single-expression methods
def full_name = "#{first_name} #{last_name}"
def admin?    = role == "admin"
def to_s      = name

Data Class

Data.define (Ruby 3.2+) creates immutable value objects—similar to records but without the full record semantics:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Measurement = Data.define(:value, :unit)

m = Measurement.new(value: 100, unit: "kg")
m.value  # => 100
m.unit   # => "kg"

# Values are frozen
m.value = 200  # => FrozenError

# With equality by value
Measurement.new(value: 100, unit: "kg") == Measurement.new(value: 100, unit: "kg")
# => true

Tooling

Version Management

rbenv remains reliable and widely used. For new setups, mise manages Ruby alongside Node.js, Python, and other runtimes from a single .tool-versions file:

1
2
3
4
# .tool-versions
ruby 3.4.1
node 22.0.0
python 3.12.0
1
2
mise install   # Installs all versions
mise use ruby@3.4.1  # Set globally

Testing

RSpec remains the dominant testing framework for Rails applications:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
RSpec.describe OrderService do
  describe "#create" do
    subject(:service) { described_class.new(user: user) }
    let(:user) { create(:user) }

    context "with valid attributes" do
      it "creates an order and enqueues fulfillment" do
        expect {
          service.create(items: [{ sku: "ABC", qty: 2 }])
        }.to change(Order, :count).by(1)
         .and have_enqueued_job(FulfillOrderJob)
      end
    end

    context "with insufficient stock" do
      before { allow(Inventory).to receive(:available?).and_return(false) }

      it "raises InsufficientStockError" do
        expect { service.create(items: [{ sku: "ABC", qty: 100 }]) }
          .to raise_error(InsufficientStockError)
      end
    end
  end
end

Minitest ships with Rails and is preferred in some codebases for its simplicity and speed.

RuboCop

The universal Ruby linter. Most Rails projects enforce it in CI:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# .rubocop.yml
require:
  - rubocop-rails
  - rubocop-rspec
  - rubocop-performance

AllCops:
  NewCops: enable
  TargetRubyVersion: 3.4

Style/FrozenStringLiteralComment:
  Enabled: true
1
2
bundle exec rubocop           # Check
bundle exec rubocop -A        # Auto-correct safe offenses

When to Choose Ruby and Rails

Ruby and Rails are genuinely excellent for:

Rapid product development. Rails conventions make common patterns (authentication, admin, file uploads, email, background jobs, caching) fast to implement. A small team can ship substantial product in weeks.

CRUD-heavy applications. Blogs, admin panels, SaaS dashboards, e-commerce—the domain Rails was designed for. The Active Record pattern, form helpers, and view layer are optimized for this.

Early-stage SaaS. The combination of fast iteration and Rails 8’s single-server story (SQLite + Solid Stack + Kamal) means you can launch on $20/month and grow without infrastructure rewrites.

Small, full-stack teams. One Rails developer can own the entire stack—frontend (Hotwire), backend, database migrations, background jobs, deployment. The framework absorbs infrastructure decisions.

Where Ruby struggles:

CPU-intensive workloads. Image processing, video transcoding, heavy numerical computation—offload these to Rust, Go, or dedicated services. Ruby is not the right tool here.

Massive scale requiring horizontal distribution. Shopify runs Rails at enormous scale, but they have dedicated infrastructure teams and custom Rails internals. For most applications, you’ll hit database limits before Ruby limits, but architectural decisions matter at scale.

Teams requiring static typing as a foundation. RBS + Steep works, but it’s not TypeScript or Kotlin. If your team’s confidence comes from comprehensive type coverage, you’ll need to invest in the RBS ecosystem or consider a more typed language.

Latency-sensitive real-time systems. Sub-millisecond processing, high-frequency trading, game servers—use a compiled language.

The architecture that works well in 2026: Rails for the web tier and domain logic, PostgreSQL for persistence, Sidekiq or Solid Queue for background work, Go or Rust as escape hatches for genuinely CPU-bound tasks. This is not exotic—it’s what mature Rails shops actually run.


The Rails 8 Default Stack at a Glance

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# New app — everything you need, no cloud dependencies
rails new myapp --database sqlite3

# What you get:
# - Propshaft asset pipeline (replaces Sprockets)
# - Solid Queue (background jobs)
# - Solid Cache (caching)
# - Solid Cable (WebSockets)
# - Kamal config for deployment
# - Hotwire (Turbo + Stimulus)
# - Import maps (JavaScript without Node/webpack)
# - Authentication generator
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# A complete, modern Rails controller
class PostsController < ApplicationController
  before_action :set_post, only: %i[show edit update destroy]

  def index
    @posts = Post.published.order(created_at: :desc)
  end

  def create
    @post = current_user.posts.build(post_params)

    if @post.save
      redirect_to @post, notice: "Post published."
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_post  = @post = Post.find(params[:id])
  def post_params = params.require(:post).permit(:title, :body, :published)
end

Rails hasn’t changed what it is. It’s still opinionated, convention-over-configuration, and optimized for developer productivity. What’s changed is the surrounding ecosystem: better performance (YJIT), a real deployment story (Kamal), and infrastructure consolidation (Solid Stack) that reduces operational complexity for the applications it’s designed to build.

The language that was supposed to die is shipping.

Comments