You launched your MVP. People are paying. Now you have a different problem: growth.
Your app worked fine with 100 users. At 10,000 users, it's creaking. At 100,000, it's falling over.
Let me show you how to architect Rails for scale without rewriting everything.
The Journey
Scaling isn't about one big change. It's about many small improvements at the right time.
| Users | What Breaks First | What To Fix |
|---|---|---|
| 0 - 1,000 | Nothing | Ship features |
| 1,000 - 10,000 | Database queries | Add indexes, fix N+1 |
| 10,000 - 100,000 | Slow pages | Add caching |
| 100,000 - 1,000,000 | Background jobs | Queue everything |
| 1,000,000+ | Everything | Read replicas, sharding |
Don't optimize for a million users when you have ten. But know what's coming.
Phase 1: Clean Up Your Controllers
Before you scale infrastructure, scale your code organization.
The Fat Controller Problem
Most Rails apps start with everything in the controller:
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
@order.user = current_user
if @order.save
# Send email
OrderMailer.confirmation(@order).deliver_later
# Update inventory
@order.items.each do |item|
item.update!(quantity: item.quantity - 1)
end
# Charge payment
Stripe::Charge.create(
amount: @order.total_cents,
currency: "usd",
source: params[:payment_token]
)
# Create invoice
Invoice.create!(order: @order, user: current_user)
# Send Slack notification
SlackNotifier.notify("New order ##{@order.id}")
redirect_to @order
else
render :new
end
end
end
This works. It's also impossible to test, debug, or understand six months later.
Service Objects
Extract business logic into service objects:
# app/services/order_creator.rb
class OrderCreator
def initialize(user, order_params)
@user = user
@order_params = order_params
end
def call
Order.transaction do
create_order
update_inventory
charge_payment
create_invoice
send_notifications
end
rescue => e
Rails.logger.error("Order creation failed: #{e.message}")
false
end
private
def create_order
@order = @user.orders.build(@order_params)
@order.save!
end
def update_inventory
@order.items.each do |item|
item.update!(quantity: item.quantity - 1)
end
end
def charge_payment
Stripe::Charge.create(
amount: @order.total_cents,
currency: "usd",
source: @order_params[:payment_token]
)
end
def create_invoice
Invoice.create!(order: @order, user: @user)
end
def send_notifications
OrderMailer.confirmation(@order).deliver_later
SlackNotifier.notify("New order ##{@order.id}")
end
end
Now your controller is simple:
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def create
if OrderCreator.new(current_user, order_params).call
redirect_to order_path(@order)
else
render :new
end
end
end
Concerns for Shared Logic
Use concerns for cross-cutting logic:
# app/models/concerns/archivable.rb
module Archivable
extend ActiveSupport::Concern
included do
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
end
def archive
update(archived_at: Time.current)
end
def unarchive
update(archived_at: nil)
end
def archived?
archived_at.present?
end
end
Include it anywhere:
class Project < ApplicationRecord
include Archivable
end
class User < ApplicationRecord
include Archivable
end
Phase 2: Database Optimization
The database is usually your first bottleneck.
Add Indexes
Find slow queries:
# Enable logging in development
ActiveRecord::Base.logger = Logger.new(STDOUT)
Look for slow lines and add indexes:
class AddIndexesToOrders < ActiveRecord::Migration[7.0]
def change
add_index :orders, :user_id
add_index :orders, :created_at
add_index :orders, [:user_id, :created_at]
add_index :users, :email, unique: true
end
end
Fix N+1 Queries
# Bad - 1 + 100 queries
orders = Order.all
orders.each do |order|
puts order.user.name
end
# Good - 2 queries total
orders = Order.includes(:user).all
orders.each do |order|
puts order.user.name
end
Use the bullet gem to detect N+1:
# Gemfile
group :development, :test do
gem "bullet"
end
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.rails_logger = true
end
Counter Caches
class AddCommentsCountToPosts < ActiveRecord::Migration[7.0]
def change
add_column :posts, :comments_count, :integer, default: 0
Post.find_each { |post| Post.reset_counters(post.id, :comments) }
end
end
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
Phase 3: Caching
Russian Doll Caching
<%# app/views/posts/show.html.erb %>
<%= cache @post do %>
<h1><%= @post.title %></h1>
<%= @post.body %>
<% @post.comments.each do |comment| %>
<%= cache comment do %>
<div class="comment">
<strong><%= comment.author %></strong>
<%= comment.body %>
</div>
<% end %>
<% end %>
<% end %>
Low-Level Caching
class ReportGenerator
def self.daily_sales(date)
Rails.cache.fetch("daily_sales/#{date}", expires_in: 1.day) do
Order.where(created_at: date.all_day).sum(:total_cents)
end
end
end
Phase 4: Background Jobs
What to Queue
- Emails
- Payments
- Reports
- File uploads
- External API calls
- Image processing
Solid Queue
class ReportGeneratorJob < ApplicationJob
queue_as :default
def perform(user_id, date_range)
user = User.find(user_id)
report = ReportService.generate(user, date_range)
ReportMailer.ready(user, report).deliver_now
end
end
ReportGeneratorJob.perform_later(current_user.id, Date.today..Date.today + 30)
Phase 5: Read Replicas
# config/database.yml
production:
primary:
database: myapp_production
username: myapp
password: <%= ENV["DATABASE_PASSWORD"] %>
replica:
database: myapp_production_replica
username: myapp
password: <%= ENV["REPLICA_PASSWORD"] %>
replica: true
ActiveRecord::Base.connected_to(role: :reading) do
User.where(active: true).count
end
Phase 6: Modular Monolith
Organize by feature:
app/
├── components/
│ ├── billing/
│ │ ├── app/
│ │ │ ├── controllers/
│ │ │ ├── models/
│ │ │ ├── services/
│ │ │ └── views/
│ │ └── billing_component.rb
│ ├── inventory/
│ │ └── inventory_component.rb
│ └── users/
│ └── users_component.rb
# app/components/billing/billing_component.rb
module Billing
class Component
def self.charge_user(user_id, amount)
User.find(user_id).charge(amount)
end
def self.invoice_for(user_id)
Invoice.where(user_id: user_id).last
end
end
end
Monitoring
Key Metrics
| Metric | What It Tells You |
|---|---|
| Response time | App speed |
| Error rate | Failures |
| DB query time | DB bottleneck |
| Queue size | Jobs backlog |
| Memory usage | Leaks |
| CPU usage | Compute bound |
Health check:
# config/routes.rb
get "/up", to: "health#show"
# app/controllers/health_controller.rb
class HealthController < ApplicationController
def show
ActiveRecord::Base.connection.execute("SELECT 1")
Redis.current.ping
render plain: "OK", status: :ok
rescue => e
render plain: "ERROR: #{e.message}", status: :internal_server_error
end
end
Real-World Examples
- GitHub: Optimized monolith, millions of users
- Shopify: Indexing, caching, background jobs, read replicas
- Basecamp: Start simple, add complexity only when necessary
Scaling Checklist
At 1,000 Users
- [ ] Fix N+1 queries
- [ ] Add indexes
- [ ] Move logic to services
At 10,000 Users
- [ ] Russian doll caching
- [ ] Queue emails/jobs
- [ ] Monitoring setup
At 100,000 Users
- [ ] Read replicas
- [ ] Counter caches
- [ ] Rate limiting
At 1,000,000 Users
- [ ] Modular monolith
- [ ] Shard large tables
- [ ] Evaluate microservices
Summary
| Phase | Action |
|---|---|
| Code organization | Service objects, concerns |
| Database | Indexes, N+1 fixes, counter caches |
| Caching | Russian doll, low-level |
| Background | Solid Queue, Sidekiq |
| Database scaling | Read replicas |
| Architecture | Modular monolith |
Monitor. Measure. Improve.
Next post: Free Email Testing in Rails with RSpec