Database Guide · Scaling

Database Scaling Strategies: From 10K to 100M Users

You don't need sharding until you need sharding. Most "we need to scale" incidents I've seen were missing indexes or N+1 queries wearing a scale costume. This guide walks the full ladder — indexes, queries, replicas, caching, pooling, partitioning, sharding — with the specific numbers where each lever stops working.

~14 min read · For backend engineers · Related: Backend Architecture Patterns →

Scaling Stages Overview: The Growth Ladder

Each user-growth stage has one dominant bottleneck and one cheap first fix. Jump stages (adding sharding at 50K users) buys complexity without solving your actual problem. Diagnose first, then apply the fix matched to your stage.

Stage Typical Bottleneck First Fix
0–10K usersMissing indexes; N+1 queries (p95 > 200ms)Add indexes, EXPLAIN everything, fix ORM lazy loading
10K–100K usersDB CPU 70%+ from read load; connection exhaustion1–2 read replicas, app-level caching of hot objects, PgBouncer
100K–1M usersCache misses on deep pages; write IOPS saturating diskDedicated Redis tier, keyset pagination, move blobs to object storage
1M–10M usersSingle-instance write ceiling (~5–20K writes/sec); working set > RAMVertical upgrade to limit, table partitioning, async queues for writes, archive cold data
10M+ usersWrite scaling beyond one box; blast radius of single instanceSharding by tenant/user ID, CQRS read models, service-owned databases

The pattern is consistent: boring fixes first. Instagram ran 90M+ photos on a handful of Postgres instances with aggressive indexing and application-level sharding done late and deliberately. Nobody's p99 was ever saved by a distributed database they didn't understand yet.

Indexing Done Right

A composite index follows one rule: equality columns first (any order), then range/sort column last. An index that can serve equality filters plus an ORDER BY turns a 500ms sort-scan into a 2ms index scan. Over-indexing then taxes every INSERT/UPDATE/DELETE — each extra index adds roughly 10–15% to write cost.

The composite index rule

-- Query: find orders for a customer in a date range, newest first
SELECT * FROM orders
WHERE customer_id = 42
  AND status = 'shipped'
  AND created_at >= '2026-01-01'
ORDER BY created_at DESC;

-- Correct: two equality columns, then the range/sort column LAST
CREATE INDEX idx_orders_cust_status_created
    ON orders (customer_id, status, created_at DESC);

-- Wrong: range column in the middle kills the rest of the index.
-- After created_at is range-scanned, status/customer_id can't be used.
CREATE INDEX idx_orders_wrong ON orders (customer_id, created_at, status);

Why order matters: a B-tree index works like a phone book sorted by (last name, first name). You can find all "Smith, J*" instantly, but you cannot efficiently find all people whose first name is "Jamie". Equality columns define contiguous ranges; only one range column can ride along at the tail.

Covering indexes: skip the table entirely

-- Hot dashboard query, runs 800 times/sec
SELECT status, COUNT(*) FROM orders
WHERE customer_id = 42 GROUP BY status;

-- Index-only scan: every referenced column lives in the index,
-- zero heap fetches. ~1-3ms instead of ~40ms.
CREATE INDEX idx_orders_cust_covering
    ON orders (customer_id) INCLUDE (status);

-- Postgres: verify with EXPLAIN (ANALYZE, BUFFERS)
EXPLAIN (ANALYZE, BUFFERS)
SELECT status, COUNT(*) FROM orders WHERE customer_id = 42 GROUP BY status;
-- Look for: Index Only Scan ... Heap Fetches: 0

Reading EXPLAIN in 60 seconds

The over-indexing penalty

Query Optimization: The Cheapest Scaling There Is

Kill the N+1, stop using SELECT *, and replace OFFSET pagination past page ~10 with keyset (cursor) pagination. These three fixes routinely cut database load by 5–10x without touching infrastructure.

The N+1 problem

// BAD: 1 query for posts + 1 query PER post for comments
const posts = await db.post.findAll({ limit: 20 });
for (const post of posts) {
    post.comments = await db.comment.findAll({ where: { postId: post.id } });
}
// Total: 21 queries, ~21 x 3ms round trips = 63ms+ network alone

// FIX 1: eager loading (join or two-query strategy)
const posts = await db.post.findAll({
    limit: 20,
    include: [db.comment],   // ORM issues 2 queries total, joins in memory
});

// FIX 2: manual batch IN — often faster than a giant JOIN
const ids = posts.map(p => p.id);
const comments = await db.comment.findAll({ where: { postId: { [Op.in]: ids } } });
// Then group comments by postId in application code

Detect N+1s: log all SQL in dev, alert on any request issuing > 20 similar queries. Most ORMs have a built-in counter (ActiveRecord::Base.connection queries, Django AssertNumQueries, Sequelize logging).

Why SELECT * hurts

Pagination at depth: OFFSET collapses, keyset doesn't

-- OFFSET: reads and discards all skipped rows.
-- Page 5000 (OFFSET 100000): scans 100K+ rows to return 20. ~800ms.
SELECT * FROM feed_posts ORDER BY created_at DESC LIMIT 20 OFFSET 100000;

-- Keyset/cursor: jumps straight into the index. Constant ~2ms at any depth.
-- Cursor encodes (created_at, id) of the last item on the current page.
SELECT * FROM feed_posts
WHERE (created_at, id) < ('2026-08-01T10:00:00Z', '987654')
ORDER BY created_at DESC, id DESC
LIMIT 20;
Approach Latency @ Page 5000 Random Jump? Use When
OFFSET/LIMIT~800ms (linear growth)YesFirst pages only, admin tables, small result sets
Keyset (cursor)~2ms (constant)No (infinite scroll friendly)Feeds, timelines, APIs, anything paginated deeply

Read Replicas: Cheap Reads, With Strings Attached

Replicas multiply read throughput but lag the primary by anywhere from 10ms to tens of seconds under load. Any feature reading its own writes must be pinned to the primary or use version tokens — otherwise you ship the classic "I posted a comment and it disappeared" bug.

What replication actually gives you

Read-your-writes: session pinning

// After any write, pin subsequent reads in this session to the primary
// for a window longer than observed max lag (e.g., 3s).
class RoutingDataSource {
    async execute(req, queryFn) {
        const pinned = req.session.pinnedUntil > Date.now();
        const target = pinned ? this.primary : this.replicaPool.random();
        return queryFn(target);
    }
}
// Set session.pinnedUntil = Date.now() + 3000 immediately after any UPDATE/INSERT.

Version tokens: precise, less wasteful

// Client sends X-Version-Token header it got back with its last write.
// Token carries the primary's LSN/watermark at write time.

app.get('/api/posts/:id', async (req, res) => {
    const token = req.headers['x-version-token'];
    if (token && !replicaCaughtUpTo(token)) {
        // Replica hasn't applied this write yet — fail over to primary.
        return queryPrimary(req.params.id);
    }
    return queryReplica(req.params.id);
});

When replicas don't help

Caching Layers: The Biggest Win and the Classic Trap

A well-placed cache converts a 50ms DB query into a ~1ms Redis GET — but only invalidation is hard. Cache immutable-ish data aggressively with TTLs, invalidate event-driven on writes, and always guard hot keys against stampedes with locks or probabilistic early refresh.

Redis vs Memcached

Redis Memcached
Data structuresStrings, hashes, sets, sorted sets, streamsStrings only
Persistence / replicationRDB/AOF, replicas, failoverNone (pure L1-style cache)
Pick it whenNeed sorted sets (leaderboards), pub/sub, counters, durabilitySimple string cache, multithreaded, dead-simple ops

Invalidation strategies

Cache stampede protection (mutex/lock pattern)

async function getWithStampedeProtection(key, ttlSec, loader) {
    let value = await redis.get(key);
    if (value) return JSON.parse(value);

    // Try to acquire a short-lived lock so ONE worker rebuilds the key
    const lockKey = `lock:${key}`;
    const acquired = await redis.set(lockKey, workerId, 'NX', 'EX', 10);
    if (!acquired) {
        // Someone else is rebuilding: briefly poll instead of hammering the DB
        for (let i = 0; i < 20; i++) {
            await sleep(50);
            value = await redis.get(key);
            if (value) return JSON.parse(value);
        }
        throw new Error('cache_rebuild_timeout');
    }

    try {
        value = await loader();                       // expensive DB call, once
        await redis.set(key, JSON.stringify(value), 'EX', ttlSec);
        return value;
    } finally {
        await redis.del(lockKey);
    }
}

// Belt-and-braces extras:
// - Stale-while-revalidate: serve slightly-stale copy while rebuilding
// - Probabilistic early expiry (XFetch): refresh before TTL as popularity grows

Hit-rate math: why 95% is the magic neighborhood

If a DB query costs ~50ms and a cache hit ~1ms, average latency = (hitRate × 1ms) + ((1 − hitRate) × 50ms).

Hit Rate Avg Latency DB Load vs No Cache
80%10.2ms20%
90%5.9ms10%
98%2.0ms2%

Going 80%→90% halves latency; going 90%→95% halves DB load again. Chasing the last 3% usually means caching low-value long-tail keys — watch hit rate per key-class, not just global.

Connection Pooling & Limits

Databases are not web servers: every connection is a process/thread costing roughly 1–10MB RAM, and context-switching above a few hundred active connections degrades everyone. Put a pooler in front, size pools small (cores × 2 is the classic starting point), and never let app servers open connections per request.

Why max_connections kills you

PgBouncer modes

Mode Server Conn Assigned Constraints Verdict
SessionFor the client's whole sessionNo reuse between requests; little savingsAvoid
TransactionPer transactionNo session state: no prepared statements (pre-1.21), no SET/ LISTEN, no advisory locks spanning txnsDefault choice — 10–100x connection multiplexing
StatementPer statementMulti-statement transactions forbidden outrightOnly for stateless read APIs

Sizing guidance

# Pool size heuristic (HikariCP formula, works for most OLTP):
pool_size ≈ cores * 2 + effective_spindle_count

# Modern SSD/NVMe boxes: cores * 2 is a fine start.
# 8-core DB -> pool of ~16-20 ACTIVE server connections. That's it.

# PgBouncer transaction mode:
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
default_pool_size = 20          ; real server connections
max_client_conn = 2000          ; cheap client-side slots
reserve_pool_size = 5
server_idle_timeout = 60

# Verify saturation before growing:
# SELECT state, count(*) FROM pg_stat_activity GROUP BY state;
# If 'active' rarely hits default_pool_size, the pool is NOT the bottleneck.

Vertical vs Horizontal: Bigger Box Is Underrated

Until roughly 64 vCPUs / 512GB+ RAM / NVMe storage, a bigger single instance beats distribution on every axis except fault tolerance. Scale vertically as far as the budget allows; split only when writes exceed what one machine commits durably — that's around 10K–30K durable transactions/sec on modern cloud hardware for Postgres-class databases.

When the bigger box wins

Realistic ceilings (single instance, cloud NVMe, ballpark)

Resource Comfortable Ceiling Hard Wall
RAM (working set)256–512GBInstance family max (~6–24TB on bare metal)
Durable writes5K–20K TPSWAL/fsync throughput: ~30K–50K small commits/sec
Read QPS (indexed, cached)50K–150K with replicasCPU: ~200K+ simple point selects/sec/core-cluster
Table size (with good indexes)1–3TB, billions of rowsMaintenance windows: VACUUM/reindex/rebuild-replica times become days

The point where you actually must split

Partitioning & Sharding: Know the Difference

Partitioning splits one table inside one instance (zero-downtime, transparent to queries); sharding splits data across many machines (real distribution, real pain). Exhaust partitioning first — it solves maintenance and prune-scan problems without touching your consistency model.

Partitioning: within one instance

-- Range partitioning by month: the workhorse
CREATE TABLE events (
    id BIGINT GENERATED ALWAYS AS IDENTITY,
    user_id BIGINT NOT NULL,
    payload JSONB,
    created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_07 PARTITION OF events
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE events_2026_08 PARTITION OF events
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

-- Queries with a date filter prune to 1 partition instead of scanning years.
-- Dropping old data = DROP TABLE events_2026_07 (instant, no DELETE bloat).
-- Other schemes: LIST (region, tenant_tier), HASH (even spread by user_id).

Sharding: across instances

Each shard owns a disjoint slice of rows and runs on its own machine. The router maps each query to shard(s) via the shard key.

Shard key selection rules

Resharding pain (why choosing right the first time matters)

// Resharding = double-write + backfill + dual-read + cutover.
// Typical timeline for a 2TB dataset: 2-4 months of engineering.

// Phase 1: dual-write to old + new layout (new one may lag; tolerate)
await legacyDb.write(row);
await newShardMap.write(row).catch(logDualWriteFailure); // never block prod

// Phase 2: backfill historical rows chunk-by-chunk (batch 1K rows/sec)

// Phase 3: dual-read with comparison (read old, read new, diff, alert)
// Phase 4: flip reads to new, keep writes mirrored for rollback window
// Phase 5: stop legacy writes; retire old tables

Avoid cross-shard transactions

Checklist: signs you ACTUALLY need sharding

SQL vs NoSQL: Decision Framework, Not Hype

Choose by access pattern and integrity requirements, not benchmarks or fashion. Relational databases handle far more scale than their reputation suggests — Postgres happily serves millions of users — while document stores win on flexible schemas and known-key access. Many mature stacks end up polyglot: one source-of-truth relational DB plus purpose-built stores at the edges.

Your Access Pattern Fit Why
Relational integrity matters: money, inventory, orders, multi-entity transactionsSQL (Postgres/MySQL)ACID transactions, FK constraints, unique constraints enforce correctness at the storage layer
Flexible/evolving schema, entity-per-document access ("get product by ID")Document (MongoDB/DynamoDB/Firestore)No joins needed if the aggregate is fetched whole; schema changes are data changes
Known query patterns only, massive key-value scale (sessions, carts, profiles)Wide-column/KV (DynamoDB, Cassandra)Predictable single-digit-ms at any scale; but design tables per query upfront — ad-hoc queries don't exist
Heavy aggregation/analytics over append-only eventsColumnar (ClickHouse/BigQuery/Snowflake)Column compression + vectorized scans: 10–100x faster aggregations than row stores
Full-text search, fuzzy matching, facetingSearch engine (Elasticsearch/OpenSearch)Inverted indexes; not a system of record — sync from primary DB
Deep relationships/traversals (social graphs, fraud rings)Graph (Neo4j) or recursive CTEs in SQLVariable-depth traversals; shallow ones (depth ≤ 3) are fine with SQL CTEs

Polyglot persistence reality check

Cost Optimization: Scaling Without Burning Money

The biggest database cost lever isn't instance size — it's not storing or querying data you don't need hot. Tier storage, archive cold data, and match managed tiers to workload shape. Numbers below are ballpark cloud pricing (AWS RDS/GCP Cloud SQL class, mid-2026) — treat as ±30% and check current rates.

Managed DB tiers (ballpark, monthly)

Tier Typical Spec Ballpark Cost/Mo Right For
Burstable/small2 vCPU, 8GB, gp3$100–200< 10K users, dev/staging
General purpose8 vCPU, 32GB, NVMe/gp3$500–900Most production apps to ~1M users
Memory-optimized16–32 vCPU, 128–256GB$2K–5KLarge working set, 1M–10M users
Serverless (Aurora Serverless v2 class)Scales 0.5–128 ACUUsage-based; ~$0.12/ACU-hrSpiky/idle-heavy traffic, dev — NOT steady high load (often pricier there)

Storage tiering & cold archival

-- Find the tables worth archiving (Postgres):
SELECT relname AS table,
       pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
       n_live_tup AS rows
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;
-- Anything > 50GB with append-only semantics is an archiving candidate.

Continue Learning

Scaling the database is one layer. Pair it with API design discipline and a structured growth path as a backend engineer.