yaml ruby 93 lines · 4 tabs

Read Replica Routing for GET-Heavy Endpoints

Shared by codesnips Jan 2026
4 tabs
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
4 files · yaml, ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Read Replica Routing for GET-Heavy Endpoints — share card
Link copied