production:
primary:
adapter: postgresql
database: app_production
username: app
password: <%= ENV["PRIMARY_DB_PASSWORD"] %>
host: <%= ENV["PRIMARY_DB_HOST"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
primary_replica:
adapter: postgresql
database: app_production
username: app_readonly
password: <%= ENV["REPLICA_DB_PASSWORD"] %>
host: <%= ENV["REPLICA_DB_HOST"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
replica: true
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: {
writing: :primary,
reading: :primary_replica
}
def self.reading(&block)
connected_to(role: :reading, &block)
end
def self.writing(&block)
connected_to(role: :writing, &block)
end
end
class DatabaseSelector
DELAY = 5.seconds
def initialize(app)
@app = app
end
def call(env)
request = ActionDispatch::Request.new(env)
if reading_request?(request)
ApplicationRecord.reading { @app.call(env) }
else
ApplicationRecord.writing { @app.call(env) }
end
end
def self.record_write(session)
session[:last_write] = Time.now.to_f
end
private
def reading_request?(request)
return false unless request.get? || request.head?
last_write = request.session[:last_write]
return true if last_write.nil?
Time.now.to_f - last_write.to_f > DELAY.to_f
end
end
class ArticlesController < ApplicationController
def index
@articles = Article.published.order(created_at: :desc).limit(50)
render :index
end
def show
@article = Article.find(params[:id])
render :show
end
def create
@article = Article.new(article_params)
if @article.save
DatabaseSelector.record_write(session)
redirect_to article_path(@article), notice: "Article created"
else
render :new, status: :unprocessable_entity
end
end
private
def article_params
params.require(:article).permit(:title, :body)
end
end
This snippet shows how a GET-heavy Rails app can automatically route safe read traffic to a Postgres read replica while keeping writes on the primary, using ActiveRecord's built-in multiple-database support. The core idea is that read replicas trade a little staleness for a lot of read capacity, so the goal is to send as many queries as possible to the replica without breaking read-your-writes semantics for a user who just mutated data.
In database.yml, the production environment declares two connections under a single database group: a primary pointing at the writer and a primary_replica marked replica: true. Marking a connection as a replica tells ActiveRecord never to run migrations against it and to treat it as read-only, which guards against accidental writes leaking to a node that can't accept them.
In ApplicationRecord, connects_to wires those two physical connections to logical roles: writing and reading. connected_to(role: :reading) later swaps the whole model layer onto the replica pool for the duration of a block. Defining reading and writing helpers keeps that intent readable at call sites.
DatabaseSelector middleware is where the routing policy lives. It inspects each request: anything that isn't GET or HEAD is a write and runs on the primary. For safe verbs it checks a short-lived last_write value stashed in the session by record_write. If the user wrote within DELAY seconds, the request stays on the primary so they read their own changes; otherwise it is routed to the replica via connected_to. This session timestamp is the pragmatic answer to replication lag — rather than querying replica position, it simply pins recent writers to the writer for a few seconds.
ArticlesController demonstrates the payoff: the controller code is completely unaware of routing. index and show are plain reads that will land on the replica when the middleware allows it, while create performs a write and calls DatabaseSelector.record_write to set the stickiness window. The trade-off is that the window is time-based, not lag-aware, so a badly lagging replica could still serve stale data once the window expires; tuning DELAY against observed lag, or using GUARD on primary connections, is the usual mitigation. This pattern scales reads cheaply and is the first lever most teams reach for before sharding.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.