jaugusto.dev

Software Engineer

From Overloaded to Scalable: A Playbook for Read-Heavy Systems

Most read-scaling problems share the same shape: a system is read-heavy (far more reads than writes), and the database is struggling to keep up. The right approach is to climb one step at a time, measuring before moving to the next, rather than reaching for the most complex solution first. Cheaper fixes usually resolve most of the problem before anything elaborate is needed.

Step 0: Measure, Then Optimize

Before adding any infrastructure, find out where the time is actually going. Look at slow or expensive queries first: the cheapest and most common fix is a missing index or an inefficient query pattern. A lookup without proper indexing can scan an entire dataset and consume most of the available capacity on its own.

Trade-off: better indexing speeds up reads almost for free, but adds a small cost to every write and consumes extra storage. This is a good deal when writes are rare; it would backfire in a write-heavy system. If this step alone resolves the bottleneck, stop here; there's no need to scale further.

Step 1: Add a Cache Layer

If a small set of records accounts for most of the traffic, hitting the database on every read is wasteful. Placing a cache in front of the database, checking it first and only falling back to the database on a miss, removes the majority of repeated reads before they ever reach storage.

Impact: with a high hit ratio, only a small fraction of total reads ever reach the database, often reducing load by an order of magnitude with comparatively little added infrastructure.

Watch out for: Stale Data

Caching trades some consistency for speed and availability. The real question is: how much staleness can this specific data tolerate? Content that changes rarely and is read often can be cached aggressively. Data where correctness at every instant matters should either avoid caching or be invalidated immediately on write.

Watch out for: Hotspots

If one record becomes disproportionately popular, it can concentrate most of the traffic onto a single point. Two failure patterns tend to follow: a stampede, where the moment that record's cached entry expires, a large volume of requests miss at the same time and hit the database together; and uneven load, where in a distributed cache all the traffic for that one record lands on a single node while the rest sit idle, and adding more nodes doesn't help.

Common ways to reduce this risk: randomize expiration times slightly so entries don't all expire at once; allow only one request to reload a missing hot record while others wait for that result; duplicate very hot records across multiple cache entries and pick one at random on each read; and keep a very short-lived local cache close to the application to absorb sudden spikes before they reach the shared cache.

Step 2: Add Read Replicas

A cache absorbs repeated reads, but plenty of reads are still varied: searches, listings, less popular records. For those, read replicas help: the primary database continues to handle the (comparatively rare) writes, while one or more secondary copies handle read traffic.

Watch out for: Replication Lag

Replicas typically trail the primary by a small delay. A write that just happened on the primary might not yet be visible on a replica. Usually this is fine: a reader seeing new content a couple of seconds late rarely matters. It becomes a problem in "read your own writes" situations, where the person who just made a change needs to see it immediately; that specific read should go to the primary rather than a replica, at least for a short window afterward.

The tightness of that sync is itself a trade-off. Keeping replicas strictly in sync gives strong consistency but can slow writes and reduce availability if a replica lags or fails. Letting replicas catch up asynchronously keeps writes fast and availability high, at the cost of some delay before reads reflect the latest write. Systems where correctness at every moment matters lean toward the first; systems that can tolerate a short delay lean toward the second.

Step 3: Don't Forget Connection Handling

As more application instances and more replicas come into play, the number of open connections to the database grows too. Opening a fresh connection for every request is expensive and can exhaust the database's connection capacity, sometimes well before the actual query load becomes the limiting factor.

Reusing a pool of already-open connections across requests, rather than opening and closing one each time, avoids this. It's an easy detail to overlook, but scaling the application layer without it can bring down the database through connection exhaustion rather than genuine overload.

The Physical Ceiling

Read replicas don't scale indefinitely. Every write still passes through a single primary, so replicas scale reads, not writes. Each replica has to apply every write, the same as the primary does; it isn't doing less work, just work of the same kind, so beyond a point, replication itself becomes the bottleneck. The more replicas there are, the more replication lag tends to grow, since changes must propagate further. And any single machine has a hard limit on CPU, memory, network, and disk, so scaling vertically eventually becomes disproportionately expensive.

If writes start growing as well, or this ceiling is reached, the next step isn't about reads anymore; it shifts toward splitting the data itself across multiple databases, a different problem with its own trade-offs.

Summary

The overall path is to start with query and index optimization, which fixes wasted CPU and IO per query at the cost of slightly slower writes, and is worth doing almost always as the natural starting point. From there, a cache layer handles repeated reads, trading some staleness and hotspot risk for a large drop in database load, and fits well when the data can tolerate being slightly out of date. Read replicas then take on the volume of varied reads that a cache can't absorb, at the cost of replication lag, and work well when reads can tolerate a short delay before seeing the latest write. Underneath all of this, connection pooling should simply always be in place, since it prevents connection exhaustion with essentially no downside. Each step buys meaningful headroom, but none of them scale forever, and knowing where that ceiling is, and what comes after it, matters as much as knowing the steps themselves.