Skip to content

Database Scaling Techniques: Match the Bottleneck

Core Concept LearningJuly 16, 20265 min readUpdated July 21, 2026

Checkout slows while product pages remain healthy. CPU is moderate, but the orders table shows rising lock waits and writes queue behind one hot index. Adding a read replica would not fix that incident because the bottleneck is on the write path.

This guide separates read load, write or storage limits, large-table scans, and repeated hot reads, then maps them to replicas, sharding, partitioning, or caching. User count and company size do not choose the technique; measurements on one representative request do. For machine-level scale-up versus scale-out, see Horizontal vs Vertical Scaling.

One DB overload → four scaling levers
One DB overload → four scaling levers

When one database is not enough

A single primary may own every read and write, but a slow application response does not prove that the database needs distribution. First follow one request through connection acquisition, query planning, lock waits, CPU, memory, storage I/O, and response serialization. A missing index or an unbounded query should be fixed before topology hides it.

Scale architecture only after the evidence names a capacity or availability limit. A larger instance may be the simplest safe step; replicas, partitions, caches, and shards each solve a narrower bottleneck and add routing, consistency, or operational costs.

Quick reference

  • Request flow: API wait → connection pool wait → query execution → lock/I/O/CPU wait → rows returned → application work.
  • Measure first: tail query time, connection saturation, locks, CPU, memory pressure, disk I/O, and rows scanned.
  • Optimize query shape, indexes, and pool limits before adding distributed state.
  • A larger machine buys headroom but does not remove its failure domain.
  • Choose a technique only when its target bottleneck matches the trace.

Remember this

A missing index or an unbounded query fixed before adding topology is cheaper than the routing and consistency cost of a replica, shard, or partition that was never the actual bottleneck.

Read replicas: offload SELECT traffic

A read replica is a copy of the primary that serves reads. The app sends SELECTs to replicas and writes to the primary. Replication lag means replicas can be slightly behind — fine for product pages, risky for "read your write" right after checkout unless you route that path to primary.

When to use: read-heavy workloads (catalogs, feeds, reports). When to skip: write-dominated systems where the primary is already the bottleneck — replicas do not multiply write capacity.

Zoom into one read-after-write request during replica lag
Zoom into one read-after-write request during replica lag

Quick reference

  • Primary = writes; replicas = reads.
  • Watch replication lag for freshness-sensitive reads.
  • Connection routing: read/write splitting in the app or proxy.
  • A replica can support failover only with promotion, routing, and data-loss rules that you have tested.
  • Failure path: if every replica is stale or unavailable, route freshness-critical reads to the primary or return a deliberate retryable error.
Unsafe split — checkout can read stale state
1const readDb = new Pool({ connectionString: env.REPLICA_URL });2const writeDb = new Pool({ connectionString: env.PRIMARY_URL });3 4await writeDb.query("UPDATE orders SET status = $1 WHERE id = $2", ["paid", id]);5const order = await readDb.query("SELECT status FROM orders WHERE id = $1", [id]);6// Replica lag may still return "pending".
Freshness rule + unavailable-replica path
1async function getOrder(id: string, requireFresh = false) {2  const db = requireFresh ? writeDb : readDb;3  try {4    const result = await db.query("SELECT status FROM orders WHERE id = $1", [id]);5    if (!result.rowCount) throw new Error("order_not_found");6    return result.rows[0];7  } catch (error) {8    if (!requireFresh) return getOrder(id, true); // bounded fallback to primary9    throw error;10  }11}12 13// Expected check after checkout: getOrder(id, true).status === "paid".14// Stop the replica: ordinary reads fall back; primary failure remains an error.

Remember this

Replication lag can return a stale "pending" status right after checkout unless that read-your-write path is explicitly routed to the primary — replicas scale reads, never write capacity.

Sharding: split data across databases

Sharding partitions data across separate databases (shards) by a key — user ID, tenant ID, region. Each shard holds a slice; no single node holds the whole dataset. Writes and storage scale with shard count, but cross-shard joins and transactions get hard.

When to use: data or write volume exceeds one primary even with replicas. When not to: early products still fine on one Postgres — sharding is operationally expensive (rebalancing, hot keys, routing).

Sharding: one dataset split across separate databases
Sharding: one dataset split across separate databases

Quick reference

  • Choose a shard key with even distribution (avoid hot tenants).
  • App or proxy routes each request to the right shard.
  • Cross-shard queries need scatter-gather or denormalization.
  • Tools: Vitess, Citus, Cosmos/Dynamo-style partitions — or manual ranges.
  • Failure path: an unknown shard mapping must fail closed or retry after refreshing routing metadata; never guess a destination.
Deterministic tenant router
1const shards = new Map([2  [0, new Pool({ connectionString: env.SHARD_0_URL })],3  [1, new Pool({ connectionString: env.SHARD_1_URL })],4]);5 6function shardForTenant(tenantId: number) {7  if (!Number.isSafeInteger(tenantId) || tenantId < 0) {8    throw new Error("invalid_tenant_id");9  }10  const shardId = tenantId % 2;11  const db = shards.get(shardId);12  if (!db) throw new Error(`unknown_shard:${shardId}`);13  return { shardId, db };14}
Route, verify, and fail closed
1async function loadOrder(tenantId: number, orderId: string) {2  const { shardId, db } = shardForTenant(tenantId);3  const result = await db.query(4    "SELECT id, tenant_id, status FROM orders WHERE tenant_id = $1 AND id = $2",5    [tenantId, orderId],6  );7  if (!result.rowCount) throw new Error("order_not_found");8  if (result.rows[0].tenant_id !== tenantId) throw new Error("routing_invariant_failed");9  return { shardId, order: result.rows[0] };10}11 12// Expected: tenant 43 routes to shard 1 and returns tenant_id 43.13// Remove shard 1 from the map: request fails; it never guesses shard 0.

Remember this

An unknown shard mapping has to fail closed, never guess a destination — sharding earns its rebalancing and routing cost only once write volume or storage actually exceeds one primary with replicas.

Partitioning: split large tables inside one database

Partitioning divides a large table into pieces inside the same database — by range (date), list (region), or hash. Queries that prune partitions scan less data; old partitions can be archived or dropped cheaply.

Unlike sharding, you still have one logical database and usually one primary for writes. Partitioning is often the right move before sharding when a single huge table (events, orders by month) dominates I/O.

When to use: time-series or huge fact tables with predictable filters. When not to: small tables or random access by keys that never prune.

Partitioning: one database · large table cut into pieces
Partitioning: one database · large table cut into pieces

Quick reference

  • Same DB, smaller physical pieces of a table.
  • Partition pruning = fewer blocks scanned.
  • Common: monthly order or event partitions.
  • Sharding ≠ partitioning — shards are separate DBs; partitions are intra-DB.
  • Verify with the query plan that expected partitions are pruned; a partitioned table can still scan every partition.
Postgres monthly partitions
1CREATE TABLE orders (2  id bigint GENERATED ALWAYS AS IDENTITY,3  created_at timestamptz NOT NULL,4  status text NOT NULL,5  PRIMARY KEY (id, created_at)6) PARTITION BY RANGE (created_at);7 8CREATE TABLE orders_2026_07 PARTITION OF orders9FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
Pruning check + missing-partition failure
1EXPLAIN (ANALYZE, COSTS OFF)2SELECT id, status3FROM orders4WHERE created_at >= '2026-07-01'5  AND created_at <  '2026-08-01';6-- Expected plan names only orders_2026_07.7 8INSERT INTO orders (created_at, status)9VALUES ('2026-08-01', 'placed');10-- Expected until August exists:11-- ERROR: no partition of relation "orders" found for row

Remember this

A partitioned table can still scan every partition if the query filter doesn't prune — verify pruning in the query plan itself, since partitioning stays one logical database while sharding splits into many.

Caching: serve hot data from memory

Caching keeps reusable results in a faster layer so repeated product requests do not execute the same query. Flow: app → cache → on miss → database → attempt to populate → response. The useful result is measured origin work avoided without violating freshness, not a generic hit-rate target.

When to use: frequently read data with an explicit staleness allowance. When not to: write-sensitive balances, permissions, or personalized responses without safe keys and invalidation. See Caching 101 for layers and eviction; this section only places caching in the database-scaling decision.

Zoom: App → cache hit · miss → DB → store
Zoom: App → cache hit · miss → DB → store

Quick reference

  • Cache hit = skip DB; miss = load and populate.
  • TTL + invalidation keep freshness honest.
  • Fails open: if cache is down, still read the DB.
  • Pairs well with replicas — fewer reads reach any database.
Cache-aside hit and miss
1async function getProduct(id: string) {2  const key = `product:v1:${id}`;3  const cached = await redis.get(key);4  if (cached) return JSON.parse(cached);5 6  const result = await db.query("SELECT id, name, price FROM products WHERE id = $1", [id]);7  if (!result.rowCount) throw new Error("product_not_found");8  await redis.set(key, JSON.stringify(result.rows[0]), "EX", 60);9  return result.rows[0];10}
Cache outage fails open to database
1async function getProductResilient(id: string) {2  try {3    return await getProduct(id);4  } catch (error) {5    if (!isRedisUnavailable(error)) throw error;6    const result = await db.query(7      "SELECT id, name, price FROM products WHERE id = $1",8      [id],9    );10    if (!result.rowCount) throw new Error("product_not_found");11    return result.rows[0]; // skip population while Redis is unavailable12  }13}14 15// Expected: second healthy call is a cache hit.16// Stop Redis: the product still returns from Postgres; monitor DB load.

Remember this

A cache outage should fail open straight to the database, not fail the request — the measured result that matters is origin work avoided without violating freshness, not a generic hit-rate target.

Choose the order that matches the bottleneck

There is no universal cache → replica → partition → shard ladder. Start with the wait you measured. Repeated identical reads suggest a cache; many independent reads saturating the primary suggest replicas; scans over one very large table suggest partitioning; write throughput or storage beyond one node may justify sharding. Sometimes an index, batch job, queue, or larger instance is the correct next move.

Run the decision on one checkout request: if it waits for a pool slot, reduce query duration or control concurrency; if it waits on a lock, shorten transactions or remove hot writes; if a read saturates the primary, test a replica with a freshness rule; if one tenant dominates writes, test shard-key distribution before splitting data.

Practical order: cache → replicas → partition → shard
Practical order: cache → replicas → partition → shard

Quick reference

  • Repeated read work → cache only if the result is safely reusable.
  • Read capacity or isolation → replica, with lag and fallback behavior defined.
  • Large scans with a stable filter → partition and verify pruning in the plan.
  • Write or storage ceiling → shard only after testing key distribution, routing, and rebalance.
  • Lock or connection waits → fix transactions, queries, indexes, or admission control before distributing.

Remember this

There's no universal cache → replica → partition → shard ladder — the measured wait names the fix: a lock wait means shorter transactions, not a replica; a saturated primary means a replica, not a shard.

Key takeaway

Database scaling is bottleneck matching: replicas add read capacity, partitioning helps selected queries and lifecycle operations, sharding distributes data or writes, and caching avoids repeat origin work. None repairs a bad query automatically, and each needs an explicit stale, unavailable, or misrouted path.

Practice (25 min): Capture one slow request trace and its query plan, record the dominant wait, then choose one intervention with an expected measurable result such as fewer rows scanned, shorter pool wait, or lower primary read load under the same traffic. Intentionally disable that new path or force its stale/unavailable condition; recover through the fallback you documented. Pass when the target metric improves on the same workload and the broken path produces the planned fallback rather than silent wrong data.

Share:

Related Articles

In-memory caching is an essential component of high-throughput web architectures, reducing database read load and accele

Read

A dashboard can look “fast” while users still wait, and a load test can report huge requests-per-second while p99 checko

Read

A product page feels instant on the second visit because some layer reused work from the first. The useful beginner ques

Read

Keep learning

Follow a structured path or browse all courses to go deeper.