The primary database’s CPU was constantly maxed out. The interesting part: the write rate was low. Almost all of the load was reads — report pages, listing endpoints, search. A single primary was trying to carry a pile of reads that never needed it in the first place.

Separating read and write load — read/write splitting — is the known fix for this picture. But applied at the wrong time, or without awareness of the right traps, it brings more problems than it solves.

Most load is read-heavy

How read-heavy the traffic is depends on the workload: in measurements of the standard OLTP benchmarks, TPC-E runs 90.69% reads while TPC-C stays at 65.71% — so measure your own ratio instead of assuming it. Every order is written once but read dozens of times: in the list, in the detail view, in a report, on a dashboard. This asymmetry is what makes read/write splitting appealing — because the side you need to scale is obvious.

First: is this really a capacity problem?

Stop before adding a replica. A full primary doesn’t always mean “out of capacity.” Often a single missing index makes the primary look many times busier than it is.

Adding a replica — a new server, replication setup, lag monitoring — can amount to expensively buying your way out of a problem a single CREATE INDEX would have solved. The order in the breaking points of data-intensive systems is clear: index discipline first, then replicas. Don’t skip that order.

Measure your queries with EXPLAIN ANALYZE. If the primary is genuinely saturating under correctly indexed queries — that’s when you reach for a replica.

A replica scales reads, not writes

Let’s be clear: a read replica adds nothing to your write capacity. The same writes are replayed on every replica. A replica solves a read-load problem; if you have a write-load problem, a replica is the wrong tool.

Setting it up in Laravel

Laravel supports read/write connection splitting natively:

// config/database.php
'pgsql' => [
    'driver' => 'pgsql',
    'read'   => ['host' => ['10.0.0.2']],   // replica
    'write'  => ['host' => ['10.0.0.1']],   // primary
    'sticky' => true,
    // ...shared settings
],

SELECTs go to the replica, INSERT/UPDATE/DELETEs go to the primary. The replica gets its own connection pool — separate from the primary’s; if you use pgBouncer, they are two distinct pools.

Replication lag: the real bill

The replica trails the primary by a few milliseconds — a few seconds under load. This delay is the real cost of read/write splitting, and its name is the read-after-write problem.

sticky => true partly addresses it: if you wrote within a request, the subsequent reads in that same request go to the primary. But sticky only works within the boundary of a single request.

Outside that boundary it’s still open: a user updates their profile (request 1, written to the primary), moves to the next page (request 2, read from the replica), and sees their old profile, not yet replayed on the replica. The user sees their own data as stale. This looks like a bug, but it’s actually a tradeoff the architecture accepts — one that has to be accepted deliberately.

The query classification discipline

Setting up read/write splitting requires every read to answer one question: can this query read stale data?

  • Can read from a replica: lists, reports, search results, dashboards. A few seconds of delay is irrelevant.
  • Must read from the primary: account balance, stock count, authorization checks, any read a write decision depends on.

This classification is now part of the architecture, and it needs to be documented. A new developer must have somewhere to look for “where should this query read from” — otherwise the classification quietly rots.

When you actually need it

Read/write splitting is the right move when these three conditions hold together:

  1. Index discipline is complete; queries are correctly indexed and the primary is still saturating.
  2. The load is measurably read-heavy.
  3. There’s the discipline to do and document the stale-read classification.

If any one of these is missing, deferring the replica is cheaper.


Read/write splitting is a cheaper scaling move than growing vertically — but it isn’t free. The price is replication lag, and the cost of ignoring it is paid by showing users their own data as stale.

Before splitting reads, know which reads can tolerate staleness.