Warming up the neural circuits...
By the end of this chapter you will:
Every database starts out handling everything perfectly — 100 users, 1000 rows, 10 queries per second. Then you launch. You get 10,000 users. Queries that took 5ms now take 500ms. The CPU is pegged at 100%. The database is the bottleneck. Welcome to scaling. This chapter covers the first things you do before reaching for microservices or sharding.
Buy a bigger server. More CPU, more RAM, faster disks. This is the first and simplest scaling move.
| Pros | Cons |
|---|---|
| Zero application changes | There's a ceiling — the biggest cloud instance |
| No data distribution complexity | Single point of failure |
| Instant — just resize the instance | Expensive at the top end |
| Works for 95% of applications | Downtime during resize (unless using cloud provider live migration) |
When to scale vertically: Your database has enough capacity for the next 6 months, you just need a bit more. Most startups never outgrow vertical scaling — a well-tuned PostgreSQL instance on a large AWS RDS instance (96 vCPU, 768 GB RAM) can handle 50,000+ queries per second.
The database can't handle unlimited connections. PostgreSQL forks a process per connection — 1000 connections = 1000 OS processes, each consuming ~5 MB. Your server runs out of memory before the database slows down.
PgBouncer sits between your application and PostgreSQL, maintaining a small pool of persistent connections and multiplexing application requests through them:
App (200 connections) → PgBouncer (20 connections) → PostgreSQL (20 processes)# pgbouncer.ini
[databases]
mydb = host=localhost port=5432 dbname=mydb
[pgbouncer]
pool_mode = transaction # Return connection to pool after each transaction
default_pool_size = 20 # 20 connections per pool
max_client_conn = 500 # Max incoming connectionsPool modes:
For web apps (HTTP request → query → response), transaction mode is ideal. A user's request holds a connection for ~10ms (the query duration), not the entire HTTP request lifecycle. 20 database connections can serve thousands of concurrent users.
The most common scaling pattern: one primary handles all writes, multiple replicas handle reads.
┌──────────┐
Writes → │ Primary │
└────┬─────┘
│ replication (async, usually < 1 second lag)
┌──────────┼──────────┐
┌────┴─────┐ ┌──┴──────┐ ┌┴──────────┐
│ Replica 1│ │Replica 2│ │ Replica 3 │
└──────────┘ └─────────┘ └───────────┘
↑ ↑ ↑
Reads (users) Reads (reports) Reads (search)How to use read replicas in your app:
import { Pool } from 'pg';
const primary = new Pool({ connectionString: process.env.PRIMARY_DB_URL, max: 10 });
const replicas = [
new Pool(
The replica is usually 50–500ms behind the primary. If a user writes data and immediately reads it back, they might not see their own write. Solutions:
sync replication if zero-lag is required (slower, reduces availability)Sharding (splitting data across multiple independent databases) is the nuclear option. Do these first:
EXPLAIN ANALYZE.work_mem, shared_buffers, effective_cache_size.GitHub's database scaling journey:
Lesson: GitHub was 8 years old before they needed sharding. Most companies never reach that scale. Do the simple things first.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Jumping to sharding too early | Massive operational complexity. Every cross-shard query becomes a distributed systems problem. | Exhaust all other options first (indexes, replicas, caching, partitioning). Measure, don't guess. |
| One connection per request, no pooling | 500 concurrent requests = 500 connections. PostgreSQL runs out of memory and crashes. | Use PgBouncer (transaction mode). Set default_pool_size = 20–50. |
| Application-managed connection pools, no pgbouncer | If you have 10 app instances, each with a pool of 50, that's 500 connections. The pool doesn't help if you don't pool across instances. | PgBouncer aggregates across all app instances. One pool to rule them all. |
| Writing to replicas | Replicas are read-only (unless configured otherwise). Writes silently vanish — they're not replicated back to the primary. | Route ALL writes to the primary. Route reads to replicas. Double-check your config. |
| Ignoring replication lag in user-facing features | User updates their profile, refreshes the page, sees the old profile (read from replica). They think the site is broken. | Read from primary for "your own data" operations. Use replica for content that's not time-sensitive. |
| Vertical scaling without a plan for failure | The bigger instance is still a single point of failure. When it goes down, everything goes down. | Combine vertical scaling with read replicas + automated failover. |
max_connections conservatively — 200–500 for a managed database, not 5000. Use PgBouncer to handle more clients.SELECT pg_current_wal_lsn() - replay_lsn FROM pg_stat_replication; — if this grows beyond a few seconds, investigate.auth_type = scram-sha-256.SELECT count(*) FROM pg_stat_activity;).pg_basebackup. Verify that writes on the primary appear on the replica.events table. Insert 10M rows across multiple partitions. Compare query performance for "last 24 hours" with and without partition pruning.tenant_id hashing. Handle cross-tenant queries with scatter-gather.What's the difference between vertical and horizontal scaling? Vertical scaling = bigger server (more CPU/RAM). Horizontal scaling = more servers (distributing data/work across multiple machines). Vertical is simpler but has a ceiling. Horizontal is complex but can scale nearly infinitely.
What is a read replica? A copy of the primary database that stays synchronized via replication. Reads go to replicas, writes go to the primary. This offloads read traffic and provides failover capability.
Why do you need connection pooling? Each database connection consumes memory and OS resources. Without pooling, high concurrency exhausts these resources. A pooler multiplexes many client connections through a small number of persistent database connections.
What is replication lag and how do you handle it? The delay between a write committing on the primary and that write being applied on the replica, typically 50–500ms. Handle it by: routing "read your own writes" to the primary, notifying users of a brief delay, using synchronous replication for critical data (at cost of latency), and designing UX that tolerates eventual consistency.
Walk through the steps you'd take before resorting to sharding. Optimize queries + indexes → tune PostgreSQL config (work_mem, shared_buffers) → add connection pooling → add read replicas → vertical scaling → add caching layer (Redis) → partition large tables → archive cold data → sharding as last resort. Each step should be backed by benchmarks showing the bottleneck.
What are the tradeoffs of using PostgreSQL native partitioning vs application-level sharding? Native partitioning: managed by PostgreSQL, transparent to queries (partition pruning is automatic), simpler to operate, limited to one server. Application-level sharding: full control over data distribution, can span multiple servers, but requires application logic to route queries, cross-shard operations are complex, and you lose many relational features (JOINs across shards, global uniqueness constraints).
Scaling a database is a ladder. Start with the simplest rung: optimize queries and indexes. Then add connection pooling. Then read replicas. Then vertical scaling. Then caching. Then partitioning. Only at the very top do you reach for sharding. Each rung adds complexity — make sure the previous rung is exhausted first. Connection pooling with PgBouncer (transaction mode) is the single highest-impact, lowest-effort scaling technique for most applications.