#01The Scaling Illusion
Every modern deployment platform makes application scaling look solved. Add more replicas, raise the autoscaler's ceiling, watch CPU utilization flatten out under load — the application tier is stateless by design, so scaling it is close to a mechanical exercise. A team can go from 3 instances to 30 in the time it takes to edit a YAML file, and each new instance is exactly as capable as the last one. The Primary KeyA column (or set of columns) that's unique and non-null for every row, used to identify that row unambiguously from anywhere else in the database.Learn more-bearing database at the center of that architecture is not stateless, and it does not scale the same way. It is the one component every one of those 30 instances ultimately depends on for the same rows, the same tables, the same disk. Scaling the stateless tier while leaving the stateful tier untouched doesn't remove the bottleneck — it just points thirty times more traffic at the same single point. The application layer's apparent infinite scalability is, in a very literal sense, borrowed against the database's finite capacity. This is not a rare misconfiguration. It's the default shape of almost every system that grows past its first few thousand users, because the tools for scaling application servers are mature, automatic, and cheap, while the tools for scaling a relational database are none of those things — they require real engineering decisions, made in advance of the pain, not after it.
Client
web/mobile/API
App Instance 1
stateless
App Instance 2
stateless
App Instance 3
stateless
Primary Database
single writer, finite connections
#02Why the Database Doesn't Scale Like Your Application
An application server holds no state between requests that matters once the response is sent — kill it mid-request behind a Load BalancerA component that distributes incoming traffic across multiple backend server instances, so no single instance becomes a bottleneck or single point of failure. and, at worst, one request fails and gets retried elsewhere. That property is exactly what makes horizontal scaling trivial: any instance can serve any request, so adding instances adds capacity almost linearly. A row in a relational database is the opposite of that. It has to exist in exactly one place that everyone agrees is current, because two different app instances writing conflicting updates to the same row have to be resolved by something — and that something is the database's Lock manager, WALWrite-Ahead Logging — appending every mutation to a sequential log on disk before applying it to the actual data, so a crash can be recovered from.Learn more, and single-writer model, not a load balancer. ACIDAtomicity, Consistency, Isolation, Durability — the relational database transaction guarantee.Learn more guarantees are not free; they are the database doing real, serialized work to make sure your data doesn't quietly diverge across 30 concurrent writers. That single-writer constraint is also a hardware ceiling, not just a logical one. A primary database instance has a fixed number of CPU cores, a fixed amount of RAMRandom-Access Memory — the CPU's main working memory. Reading from RAM takes roughly 50–100 nanoseconds, about 1,000x faster than an SSD.Learn more for its buffer cache, and a fixed disk IOPSInput/output operations per second — a throughput measure for how many read or write operations a storage device can handle each second.Learn more budget — and unlike the app tier, you cannot simply run two primaries and split writes between them without solving a much harder distributed-systems problem first (the same one ShardingSplitting a database horizontally across multiple independent nodes, using a shard key to decide which node owns which rows.Learn more exists to solve, covered later). Vertical scaling — a bigger box — is real and it works, right up until you hit the largest instance size your cloud provider sells, at which point there is no more "bigger" to reach for.
- CPU — every Query Plan, every index scan, every aggregation competes for the same core count
- RAM — the buffer cache holding "hot" data in memory is finite; once working data exceeds it, every query starts hitting disk
- Disk IOPS — cloud block storage has a provisioned ceiling; enough concurrent queries will queue behind it regardless of CPU headroom
- Connections — a hard, configured maximum (Postgres defaults to 100) that every app instance's Connection Pool draws from
#03Connection Pool Exhaustion: The First Wall You Hit
This is usually the very first database wall a growing system hits, and it has nothing to do with query performance — it's pure arithmetic. Postgres defaults to a maximum of 100 concurrent connections. Each of those connections is not free: Instagram's own engineering team found each Postgres connection costs roughly 1.3MB of server memory, whether or not it's actively running a query, because the database forks a real backend process per connection. Now put the application tier's easy horizontal scaling next to that number. Ten app instances, each holding a Connection Pool of 20 connections "to be safe," is already 200 connections against a 100-connection ceiling — before a single user-facing bug exists anywhere. _The application layer scaled exactly as designed, and that correct behavior is what exhausted the database's connection budget, not a mistake in the application code._The standard fix is a connection pooler — PgBouncerA lightweight connection pooler for Postgres that multiplexes many client connections down to a small pool of real database connections.Learn more being the most common — sitting between the application and the database, multiplexing hundreds of application-side connections down onto a much smaller number of real database connections, handing a physical connection to whichever queued request needs it next and returning it to the pool the instant the query finishes. PgBouncer doesn't make the database faster; it makes the finite connection budget survive an application tier that scales far faster than the database ever will.
FATAL: sorry, too many clients alreadyDETAIL: max_connections is set to 100, and 100 connections arecurrently in use — this connection was rejected.# The math that got here:# 10 app instances x pool size 20 = 200 connections requested# Postgres max_connections = 100# Result: every 101st+ connection attempt fails outright
#04The N+1 Query Problem: Death by a Thousand Cuts
Where connection exhaustion is a hard, visible wall, the N+1 Query Problem is a slow bleed — a query pattern that looks completely fine in development with ten test rows and becomes a database-killing anti-pattern the moment real data volume shows up. It's almost always introduced by an ORM's lazy-loading default, not by a developer deliberately writing a loop of queries. The shape is always the same: fetch a list of N parent records with one query, then — because the code asks for a related field on each one — the ORM silently issues one additional query per record to fetch that related data. Fetch 50 articles and then read each article's author inside a loop, and what looked like "get the articles" is actually 51 round trips to the database: 1 to list them, 50 more to resolve each author individually. At low traffic and low data volume, this is invisible — 51 queries at a few milliseconds each is not something anyone notices. The problem isn't that N+1 queries are slow in isolation; it's that the query count scales linearly with both traffic and result-set size at the same time, so a pattern that costs nothing at 10 rows and 10 requests per second can cost thousands of queries per second at 10,000 rows and moderate traffic — invisibly, until the database's CPU is pinned.
- Development environments almost never surface this — small seed data hides the multiplication entirely
- It shows up as database CPU saturation, not slow individual queries — each query is fast; there are just far too many of them
- The fix is almost always "eager loading" — one JOIN or one batched IN query instead of N separate round trips
- ORM query-count logging in staging (not just query duration) catches this before production traffic does
// N+1: fetches 50 articles, then 1 query PER article for its author// = 51 total queriesconst articles = await prisma.article.findMany({ take: 50 });for (const article of articles) {const author = await prisma.author.findUnique({where: { id: article.authorId },});}// Fixed: 1 query total, author data joined in the same round tripconst articles = await prisma.article.findMany({take: 50,include: { author: true },});
#05Lock Contention: When Concurrent Writes Collide
Connection exhaustion and N+1 queries are both, in a sense, self-inflicted — fixable entirely on the application side. Lock Contention is different: it's what happens when the application is doing everything correctly, and the bottleneck is simply that two transactions want to touch the exact same data at the exact same moment, and only one of them can. A Row Lock is the finest-grained version of this — a transaction updating one row blocks any other transaction trying to update (and sometimes even read) that same row until the first one commits or rolls back. That's usually invisible at normal traffic, because the odds of two transactions wanting the same row at the same millisecond are low. They stop being low the moment a system has a Hot Row — a single popular product's inventory count, a viral post's like counter, a shared account balance — that a disproportionate share of all traffic wants to update simultaneously. A hot row is a serialization point that no amount of connection pooling, read replicas, or application-server scaling can relieve, because every single write still has to happen one at a time, however many app instances are asking for it. A Table Lock is the same problem at a coarser grain — usually triggered by schema changes like adding a column, blocking far more concurrent activity than a row lock while it holds. Two transactions can also each hold a lock the other one needs — transaction A locks row 1 and wants row 2, transaction B locks row 2 and wants row 1 — producing a Deadlock neither can escape on its own. The database detects this cycle and forcibly aborts one of the two transactions (the "deadlock victim") to break it, which is correct behavior, but it means the application has to be written to expect and retry that abort, not treat it as an unexpected failure. The choice between Optimistic Locking and Pessimistic Locking is really a bet about how often two transactions will actually collide on the same row. Pessimistic locking takes the lock up front, guaranteeing safety at the cost of making every other transaction wait its turn — the right choice when contention is expected to be common. Optimistic locking skips the lock and checks a version number before committing, retrying if it changed underneath you — cheaper when collisions are rare, expensive (in retries) when they aren't.
| Isolation Level | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | Possible | Possible | Possible |
| Read Committed | Prevented | Possible | Possible |
| Repeatable Read | Prevented | Prevented | Possible |
| Serializable | Prevented | Prevented | Prevented |
Postgres's default is Read Committed — stricter levels prevent more anomalies at the cost of more lock waiting and more transaction retries.
#06Case Study: Notion's VACUUM Wall
By mid-2020, Notion had run its entire product on a single Postgres monolith for five years, through roughly four orders of magnitude of growth. The wall it hit wasn't a slow query or an outage — it was Postgres's own MVCCMulti-Version Concurrency Control — lets reads and writes proceed without blocking each other by keeping multiple versions of a row instead of locking it.Learn more housekeeping process, VACUUM, beginning to stall consistently under the write volume, raising a real risk of transaction ID wraparound, a failure mode serious enough to force downtime if it's ever actually reached. Notion's fix, documented on their own engineering blog, was to shard the monolith into 480 logical shards, evenly distributed across 32 physical databases, with the routing logic — which physical database a given piece of data lives on — implemented directly in their application code. They paired this with PgBouncerA lightweight connection pooler for Postgres that multiplexes many client connections down to a small pool of real database connections.Learn more specifically to keep the now-multiplied number of physical databases from independently repeating the connection-exhaustion problem described earlier. Notion's own account of this migration is explicit that it wasn't a proactive architecture choice — it was a reactive one, made after the single-primary model had already run out of headroom, not before. The team later published a second post, "The Great Re-shard," describing having to add Postgres capacity again with zero downtime — a direct illustration that a shard key and shard count chosen once are not a permanent decision; growth can force resharding a system that's already sharded.
#07Case Study: Figma's Vertical Partitioning Before Sharding
Figma's database footprint grew roughly 100x between 2020 and the end of 2022. In 2020, the entire product ran on a single Postgres database on the largest physical instance AWS offered — there was, quite literally, no bigger box left to move to. Rather than jumping straight to full horizontal sharding, Figma's database team's first real lever, documented on their engineering blog, was Vertical Partitioning. The approach: identify queries and transactions that consistently touched the same small group of tables, and — where that group turned out to be disproportionately costly — move columns out of a high-traffic table into a separate table, hosted on a different database. This relieved pressure on the hottest parts of the schema without touching the complex relational model Figma had built the rest of the product on, and without the much larger engineering lift of moving to a NoSQL model that couldn't represent that model as naturally. Figma's own framing of this is worth sitting with: vertical partitioning was explicitly a stepping stone, not a final architecture. It bought real runway quickly and cheaply, and it was also the deliberate first phase of a path toward the horizontal sharding (via their custom-built DBProxy) that came later — the easier fix first, the harder one once that stopped being enough.
#08Case Study: Instagram's Logical Shards and the Redis Offload
Instagram's early scaling story — processing tens of photo uploads and around 90 likes every second at the time — is one of the more widely cited real-world ShardingSplitting a database horizontally across multiple independent nodes, using a shard key to decide which node owns which rows.Learn more designs, in part because of how deliberately over-provisioned it was. Rather than mapping data directly onto a small number of physical database servers, Instagram's engineering team designed several thousand logical shards, mapped in code to a much smaller number of physical databases. That indirection is the entire point: moving a set of logical shards from one physical database to another — to relieve a database that's getting hot — doesn't require re-deriving the Shard Key logic or touching application code, only updating the mapping. Instagram could start with a handful of physical database servers and grow the physical fleet later, entirely independent of the sharding scheme the application already had baked in. Just as central to the design was what Instagram deliberately kept out of Postgres. The mapping of roughly 300 million photo IDs to the user ID that created them lived in RedisA single-threaded, in-memory key-value data structure store, commonly used for caching, sessions, and rate limiting.Learn more, not Postgres — a lookup needed on nearly every read, kept in memory for sub-millisecond access instead of adding that read load to the already-sharded primary fleet. The main feed and the activity feed (likes, comments) lived in Redis as well. The lesson isn't "use Redis" in the abstract — it's that Instagram treated "does this need ACID guarantees and durability" as a real per-workload question, and routed accordingly, rather than defaulting every read and write to the same database.
#09Read Replicas: The Fix With a Consistency Catch
A Read ReplicaAn asynchronous copy of a primary database used to offload read traffic — introduces replication lag, since it can trail the primary.Learn more is usually the first horizontal-scaling lever teams reach for, because it's the least invasive: an asynchronously-updated copy of the primary database that read-only queries can be pointed at, leaving the primary free to handle writes (and the reads that must be perfectly current) without competing for the same CPU and IOPS budget. The catch is baked into the word "asynchronously." A replica applies the primary's changes after they've already committed, which means there is always some Replication Lag — often milliseconds under normal load, but capable of growing to seconds or more under write-heavy load or replica resource pressure. A request that writes to the primary and then immediately reads from a replica can get back data that doesn't yet reflect its own write — a real, frequently-hit bug class, not a theoretical edge case. Read replicas scale read capacity, and only read capacity — a write-heavy workload gets no relief from adding replicas at all, since every replica still has to apply every single write the primary does, just to stay caught up. That makes replicas the right first lever for read-heavy systems (most consumer products) and close to useless for write-heavy ones (ingestion pipelines, high-frequency logging), where the primary's write capacity is the actual constraint.
#010Caching: A Release Valve, Not a Cure
RedisA single-threaded, in-memory key-value data structure store, commonly used for caching, sessions, and rate limiting.Learn more or an equivalent in-memory cache is the next lever, and for read-heavy hot paths it's often the single highest-leverage one: a cache hit never touches the database at all, so a well-cached endpoint can absorb an order of magnitude more traffic on the exact same database. The standard shape is Cache-AsideA caching pattern where the application checks the cache first, and on a miss reads the database and writes the result back into the cache.Learn more — the application checks the cache first, and on a miss, reads from the database and writes the result into the cache for next time. It's simple and it works, but it introduces the problem every caching system eventually has to solve: keeping the cache from serving data that's gone stale after the underlying row changes, without so aggressively invalidating that the cache stops helping at all. Two specific failure modes are worth naming because they look like database problems and aren't. A Thundering HerdWhen a hot cache key expires and a large number of requests miss the cache at the exact same moment, all hitting the database simultaneously.Learn more happens when a single hot cache key expires and a burst of concurrent requests all miss at once, all hit the database simultaneously to repopulate it — the exact spike-under-load pattern caching was supposed to prevent, now concentrated into one moment instead of spread out. And Cache Avalanche is the same failure at a larger scale — many keys expiring together because they were all warmed with an identical fixed TTL, producing a synchronized wave of database load instead of a steady trickle. Caching doesn't remove the database's limits — it reduces how often you hit them, which is exactly why a cache failure tends to reveal, all at once, however much real load the cache had been quietly absorbing.
#011Sharding: The Real Fix, and Its Real Cost
When vertical scaling has hit its ceiling, replicas can't help because the workload is write-heavy, and caching has already absorbed what it can, ShardingSplitting a database horizontally across multiple independent nodes, using a shard key to decide which node owns which rows.Learn more is the remaining lever — splitting one logical database across multiple physical databases, each owning a disjoint subset of the data, so both write and storage capacity scale by adding more shards rather than a bigger single box. The single most consequential decision in that design is the Shard Key — the column whose value decides which physical shard a given row lives on. Choose it well and load spreads evenly; choose it poorly and you get a hot shard — most of the traffic landing on one shard while the others sit idle, which is sharding in name only, since the real bottleneck never actually moved. Sharding also changes what kinds of queries are cheap. A query that only needs data from one shard is unaffected. A query that needs to join or aggregate across shards — count everything, look something up by a field that isn't the shard key — now has to fan out to every shard and merge the results in the application, work the database used to do for free with a single index. This is the real, permanent cost of sharding, and it's why it's the last lever reached for, not the first: it solves a real capacity ceiling by converting some previously-trivial queries into genuinely harder ones, for the lifetime of the system.
Application
routes by shard key
Shard 1
users A-H
Shard 2
users I-P
Shard 3
users Q-Z
Independent Database
own CPU, RAM, disk, connections
#012How to Actually Know the Database Is Your Bottleneck
Every lever above has a real cost — connection pooling adds an operational component, replicas add consistency bugs, caching adds invalidation complexity, sharding adds cross-shard query cost. None of them are worth reaching for speculatively. The right first step is never "add a fix" — it's confirming, with real signals, that the database specifically is the constraint, not the application code calling it. The Slow Query Log is the starting point for almost every real investigation — it names the exact queries crossing a duration threshold, not a vague "the database feels slow." From there, running the actual offending query through EXPLAIN produces its real Query Plan — whether it's using an IndexA separate, ordered data structure (commonly a B+ Tree) that maps a column's values to the physical location of their rows, letting a query jump directly to a match instead of scanning the whole table.Learn more at all, what join strategy it chose, where the time inside that one query is actually going, replacing a guess with a specific, fixable answer. Connection pool saturation, Replication Lag, and lock wait time are the three metrics worth a permanent dashboard, because each one points at a different lever from this article rather than a generic "scale up" response.
| Signal | What It Usually Means | What To Check First |
|---|---|---|
| Connection pool near 100% | App tier scaled faster than the pool | Pool size vs. instance count math (Section 3) |
| Rising replication lag | Replica can't keep up with write volume | Whether the workload is actually write-heavy (Section 9) |
| High lock wait time | Contention on a small number of hot rows | Identify the specific hot row (Section 5) |
| Slow query log growing | Missing index or a bad query plan | EXPLAIN the specific query; not the whole system |
| CPU high; queries individually fast | N+1 pattern multiplying query count | ORM query-count logging (Section 4) |
#013The Decision Ladder
Reached in the order real systems actually hit these walls, not the order that sounds most sophisticated. Skipping straight to sharding because a well-known company did it is exactly the mistake this article is arguing against — Figma, Notion, and Instagram all reached for indirection pooling, Vertical Partitioning, Read ReplicaAn asynchronous copy of a primary database used to offload read traffic — introduces replication lag, since it can trail the primary.Learn mores) before full sharding, not instead of it.
| Symptom | First Lever | When It Stops Being Enough |
|---|---|---|
| Connection errors under load | Add PgBouncer | Almost never — keep this regardless |
| Slow reads | Read replicas | Workload becomes write-heavy |
| Repeated identical reads | Redis cache-aside | Write volume; not read volume; is the constraint |
| One table dominates load | Vertical partitioning | The whole database's total volume is the ceiling; not one table |
| Single primary maxed on writes | Sharding | Last resort — after all of the above |
#014The BizTechLab Take
None of this is an argument against scaling the application tier — autoscaling, stateless deployments, and horizontal replicas are genuinely solved problems, and there's no reason to treat them with more suspicion than they deserve. The argument is narrower: that ease is not evidence of sufficiency. A system can have a perfectly-scaled, infinitely-replicated application tier and still fall over, because the one component that was never stateless the whole time was quietly absorbing all thirty instances' worth of load without anyone checking whether it could.Notion, Figma, and Instagram didn't reach for sharding first because it's the most sophisticated answer — they reached for pooling, vertical partitioning, and logical indirection first, because those are cheaper, more reversible, and solve the specific bottleneck actually being hit. Scaling a database well is not about picking the most advanced technique available; it's about correctly diagnosing which specific wall you're up against, and reaching for the cheapest lever that actually addresses it.
