---
title: "Read/Write Splitting: Separating Read and Write Load"
description: "Before scaling up the database: moving read traffic to replicas, the traps replication lag creates, and when you actually need it"
url: https://sade.dev/en/notes/read-write-splitting/
lang: en
author: "Muhammet Şafak"
published: 2026-08-22
updated: 2026-09-06
section: Note
tags: ["postgresql","database","scaling","performance"]
summary: "A read replica scales reads and adds nothing to write capacity, so the trade is not free: the price is replication lag. Laravel's sticky option repairs read-after-write only inside a single request; across two requests the user still sees the profile they just updated as stale. Do index discipline first, because a replica is often an expensive way to buy what one CREATE INDEX would have solved."
---

# Read/Write Splitting: Separating Read and Write Load

> A read replica scales reads and adds nothing to write capacity, so the trade is not free: the price is replication lag. Laravel's sticky option repairs read-after-write only inside a single request; across two requests the user still sees the profile they just updated as stale. Do index discipline first, because a replica is often an expensive way to buy what one CREATE INDEX would have solved.

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](/en/systems/data-intensive-systems-breaking-points) 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:

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

`SELECT`s go to the replica, `INSERT/UPDATE/DELETE`s go to the primary. The replica gets its own connection pool — separate from the primary's; if you use [pgBouncer](/en/notes/pgbouncer-auth-query), 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 do 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.

## Frequently asked

**Does adding a read replica increase write capacity?**

No. The same writes are replayed on every replica, so a replica adds nothing to write throughput. It solves a read-load problem; if the problem is on the write side, a replica is the wrong tool.

**What does the sticky option solve, and what does it not?**

With sticky enabled, reads that follow a write within the same request go to the primary, so a user never reads their own write as stale inside one request. It does nothing across requests: the next page can still read from a replica that has not replayed the write yet.


## Sources

- [Log-Shipping Standby Servers: streaming replication and hot standby](https://www.postgresql.org/docs/current/warm-standby.html) — PostgreSQL
- [TPC-E vs. TPC-C: Characterizing the New TPC-E Benchmark via an I/O Comparison Study](https://www.cs.cmu.edu/~chensm/papers/TPCE-sigmod-record10.pdf) — ACM SIGMOD Record 39(3), 2010
- [Database: Read and Write Connections](https://laravel.com/docs/12.x/database#read-and-write-connections) — Laravel
