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