Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

24 chapters
⌕
Beginner7›
B1The mental modelB2Client-server basicsB3Vertical vs horizontalB4Databases in designB5Caching fundamentalsB6APIs & communicationB7Walkthrough: URL shortener
Intermediate9›
I1Load balancing in depthI2CDNsI3Message queues & asyncI4Consistency modelsI5Rate limiting & throttlingI6Designing for availabilityI7Storage systemsI8Search systemsI9Walkthrough: feed/chat
Advanced8›
A1CAP theorem in depthA2Sharding at scaleA3Distributed consensusA4Fault toleranceA5Observability at scaleA6Capacity estimationA7Case studiesA8Tradeoff thinking
/ search[ ] chaptert top

System Design levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
B4

Databases in system design

The choice is decided by access pattern and consistency need — "SQL doesn't scale" is a claim that will cost you the interview.

The axis is access pattern, not scale

Candidates are taught a false dichotomy: relational databases are for small consistent things, NoSQL is for big fast things, so pick NoSQL when the prompt says "millions of users." Interviewers at senior level are specifically listening for this and will push back, because it is wrong on the facts. Relational systems run at enormous scale (a large fraction of the world's payment and social infrastructure is sharded MySQL or Postgres), and plenty of NoSQL deployments are small.

The honest framing is that these systems made different bargains. Relational databases keep joins, ad-hoc queries, and multi-row transactions — features that are hard to distribute — so distributing them is work you do yourself. Most NoSQL systems removed those features up front, which is exactly what lets them partition automatically. So the question is never "how much data?" It is: do I know my access patterns in advance, and what do I need to be true across rows at once?

Question to askPushes you relationalPushes you non-relational
Do I know every query up front?No — the product will invent new ones monthlyYes — two or three fixed lookups, forever
Do I need to combine entities at read time?Yes — joins across users, orders, itemsNo — one key returns everything needed
Do I need multi-row atomicity?Yes — transfers, inventory, bookingsSingle-key atomicity is enough
What is the write rate to one logical key?Anything a single primary can absorbBeyond one node's write capacity, and the keyspace splits cleanly
Does the shape of the data vary per record?No — a stable schema is an assetYes — heterogeneous, sparse, evolving documents
How bad is stale or lost data?Unacceptable — money, medical, legalTolerable — likes, views, telemetry, feeds
Say it like this → "I'd start relational. The access patterns here involve joining users to orders to line items, we need a transaction across two of those tables, and the product is going to keep inventing queries. If the event-log table becomes the write bottleneck I'd move that one table to a wide-column store, rather than moving the whole system."

The families, and what each is genuinely good at

FamilyData modelGenuine strengthReach for this when…
Relational (Postgres, MySQL) Tables, rows, foreign keys, a declarative query planner Ad-hoc queries, joins, ACID transactions, constraints that make invalid states unrepresentable The default. Anything with entities and relationships, and any time you can't yet enumerate the queries
Key-value (Redis, Memcached, DynamoDB in its simplest use) Opaque value behind a single key Sub-millisecond point lookups, trivially partitionable, very high throughput Caching, sessions, rate limit counters, leaderboards, feature flags — anything you always fetch by exactly one key
Document (MongoDB, DynamoDB, Couchbase) Nested JSON-ish documents, keyed, secondary indexes available Fetching one self-contained aggregate in a single read; per-record schema flexibility The read unit and the write unit are the same object — a product listing, a user profile, a CMS page
Wide-column (Cassandra, ScyllaDB, HBase, Bigtable) Partition key plus sorted clustering keys; rows can be sparse and wide Enormous write throughput on an LSM engine, linear scale-out, efficient range scans within a partition, multi-datacenter replication Time series, event logs, messages/feeds per user — huge writes, queries always scoped to one partition key
Graph (Neo4j, and graph layers on top of relational) Nodes and edges as first-class, traversal-oriented query language Variable-depth traversal — "friends of friends who like X", shortest path, fraud rings The relationships are the query and depth is unbounded; a 2-hop join in SQL is fine, a 6-hop one is not
Search (Elasticsearch, OpenSearch) Inverted index over analysed text plus filters and facets Relevance-ranked full-text search, faceting, fuzzy matching Users type free text into a box; treat it as a derived index fed from your source of truth, never as the source of truth
Columnar / OLAP (ClickHouse, BigQuery, Snowflake, Redshift) Column-oriented storage, heavy compression, vectorised scans Aggregating billions of rows over a few columns in seconds Analytics and reporting. Also the correct answer to "how do we stop the analytics team from taking production down"
Object storage (S3 and equivalents) Immutable blobs behind a key, HTTP access Effectively unlimited capacity at very low cost per GB, extreme durability Images, video, backups, data lake files. Store the bytes here and the metadata in your database — never the bytes in the database
⚠ Naming a database is not a design decision "I'll use Cassandra" earns nothing. "I'll use a wide-column store keyed by (user_id, bucket) with messages clustered by timestamp descending, because every read is the last fifty messages for one user and the write rate is 100k/sec" earns the whole section. The store follows from the key design; present them together or the interviewer cannot tell whether you understand the choice or memorised it.

The same data, modelled two ways

normalized: one fact, one place users (id, name, avatar) posts (id, author_id, body) comments (id, post_id, ...) denormalized: one document post: id, body, created_at author: name, avatar (copied) comments: [ text, author_name, ... 50 more embedded ] read = 2 joins rename author = 1 row updated read = 1 lookup, no joins rename author = N documents rewritten
Denormalization does not remove work, it moves it from read time to write time — and buys that with the risk that two copies of a fact disagree.

Normalize by default: one fact in one place means an update is one write and contradictions are impossible. Denormalize deliberately, when a measured read path is too expensive and the data is read far more often than it changes. The three costs you must name when you propose it are write amplification (one logical change touches many records), update anomalies (copies drift, and you now need a repair job), and growth (an embedded array of comments is unbounded, and most document stores have a hard per-document size limit).

NormalizedDenormalized
Read costJoins at query time, planner-dependentOne lookup by key
Write costOne rowFan-out to every copy, often asynchronously
ConsistencyEnforced by the databaseYour responsibility, and eventual at best
Schema changeOne migrationBackfill every copy
Reach for this when…Default — until profiling says otherwiseRead/write ratio is extreme, the shape is stable, and staleness between copies is acceptable

Notice the symmetry with feed design: a normalized model is fan-out-on-read, a denormalized one is fan-out-on-write. It is the same tradeoff at a different altitude, which is worth saying out loud — interviewers reward candidates who recognise a pattern they have already discussed.

Indexes: what they buy and what they cost

An index is a second data structure — usually a B-tree — that maps column values to row locations in sorted order, turning a full table scan into a logarithmic descent. That is the benefit, and it is enormous: on a ten-million-row table it is the difference between seconds and microseconds. The cost is paid on every write, and candidates almost never mention it unprompted.

INSERT one row table heap page write-ahead log (fsync) index on (user_id) index on (created_at) one logical write = four physical writes Each extra index is a permanent tax on every insert, update and delete of that table.
Indexes are not free storage tricks; they are a read/write tradeoff you are making on behalf of every future write.
  • Write amplification. Each secondary index adds a structural update per write, often at a random location in the tree. Five indexes on a hot table can cut write throughput by half or more, and index maintenance also inflates the WAL.
  • Selectivity decides everything. An index only helps when it eliminates most rows. An index on a boolean column with a 50/50 split is worse than useless — the planner will correctly ignore it, because random-access lookups for half the table cost more than a sequential scan.
  • Composite indexes are left-prefix. An index on (a, b, c) serves queries filtering on a, on a and b, or on all three; it does not serve a query filtering only on b. Order the columns by equality-filters first, then the range or sort column.
  • Covering indexes. If the index contains every column the query needs, the database never touches the table — an index-only scan, often several times faster.
  • The planner can be wrong. It chooses from cost estimates based on statistics, and stale statistics or a skewed distribution produce catastrophically bad plans, which is why "read the query plan" is a real skill.
-- The query the product actually runs
SELECT id, body FROM posts
WHERE author_id = 42 AND created_at > now() - interval '7 days'
ORDER BY created_at DESC LIMIT 20;

-- Wrong order: created_at first means every recent post by anyone is scanned
CREATE INDEX ON posts (created_at, author_id);

-- Right: equality column first, then the range/sort column
CREATE INDEX ON posts (author_id, created_at DESC);
-- Now the rows are already sorted within author_id -- the LIMIT stops early,
-- and EXPLAIN ANALYZE shows an index scan with no sort node at all.
Say it like this → "This table is write-heavy, so I want the smallest number of indexes that serve the query patterns — one composite on (author_id, created_at DESC) covers both the filter and the ordering. I'd check EXPLAIN ANALYZE for a sort node; if one appears, the index isn't doing its job."

Read-heavy and write-heavy are different problems

Read-heavy (feeds, catalogs, profiles)Write-heavy (events, telemetry, messages, ledgers)
First moveCache the hot set; reads follow a power lawBatch and buffer writes; append rather than update in place
Second moveRead replicas, with an explicit staleness policy per endpointPartition by key so writes spread across nodes
Indexing postureIndex generously — reads dominateIndex sparingly — every index taxes the hot path
Data modellingDenormalize toward the read shapeKeep writes narrow; derive read models asynchronously
Storage engine that fitsB-tree — reads land in one place, in-place updatesLSM tree — writes go to an in-memory table then flush sequentially; costs read amplification and background compaction I/O
Typical storesPostgres/MySQL plus Redis plus a CDNCassandra, ScyllaDB, RocksDB-backed systems, Kafka as a write buffer

Being able to say "B-tree for read-optimised in-place updates, LSM for write-optimised sequential flushes, and the LSM's price is read amplification plus compaction" is one of the highest-value-per-word things you can put on the whiteboard. It explains a whole category of database choices in one sentence.

Connection pooling, the bottleneck nobody draws

Databases do not accept unlimited connections, and the limit is far lower than people expect. Postgres forks a process per connection at roughly 5-10 MB of overhead each, and typical configurations cap out between 100 and 500. MySQL uses threads and is cheaper, but the shape is identical. Meanwhile a fleet of 50 app servers each holding a 20-connection pool wants 1,000 connections, and the database falls over — not from query load, but from connection count.

ConceptNumber to quoteConsequence
Useful pool sizeRoughly 2-4x the database's CPU core count, in total across all clientsBeyond that, throughput drops — you have added queueing and context switching, not capacity
Per-connection overhead~5-10 MB in PostgresIdle connections consume real memory that the page cache wanted
External pooler (PgBouncer and friends)Multiplexes thousands of client connections onto tens of server connectionsTransaction-level pooling breaks session state: prepared statements, session variables, advisory locks, and long transactions
Serverless and autoscaled functionsEach instance opens its own connections; scale-out multiplies themA pooler or a data proxy is mandatory, not optional
⚠ "Just raise max_connections" is the wrong instinct It converts a fast failure into a slow one. The database now accepts every connection and services all of them badly, so latency climbs across the board instead of a few clients being rejected. The right answer is a bounded pool plus a pooler in front, and rejecting or queueing excess work at the application edge where you can shed it cheaply.

Where "just use Postgres" is the right senior answer

A candidate who reaches for six specialised stores in a 45-minute design is describing an operational burden, not an architecture. Every additional datastore is another failure mode, another backup and restore story, another consistency boundary, and another thing your on-call has to understand at 3 a.m. One mature relational database covers a startling amount of ground:

  • Document store — JSONB columns with GIN indexes handle schemaless data honestly well
  • Key-value — a two-column table with a primary key is a fine key-value store at any scale a single node handles
  • Search — built-in full-text is genuinely good up to millions of documents before Elasticsearch earns its keep
  • Queue — SELECT ... FOR UPDATE SKIP LOCKED gives you a correct work queue, comfortably into the thousands of jobs per second
  • Geospatial, time-series, vectors — PostGIS, partitioned time ranges, and pgvector all exist and are used in production
  • Analytics — a read replica keeps reporting queries off the primary until data volumes genuinely demand a columnar engine

The point is not that Postgres is always right. The point is that consolidating on one store buys operational simplicity, and simplicity is a legitimate design goal you are allowed to argue for. State the exit conditions and you get the credit without sounding dogmatic.

Reach past Postgres when…Because
Sustained writes exceed what one primary can absorb (roughly tens of thousands per second, workload dependent)A partitioned wide-column store scales writes horizontally by design
You need active-active writes in multiple regionsSingle-leader replication cannot do it; you need multi-leader, a leaderless store, or distributed SQL like Spanner or CockroachDB
Queries are relevance-ranked free textAn inverted index with proper analysers and scoring is a different data structure
Analytics scan billions of rows over a few columnsRow storage reads columns you don't need; columnar engines are 10-100x faster here
Access is a single key at sub-millisecond latency, at very high ratesAn in-memory key-value store avoids disk, planner, and MVCC overhead entirely
The workload is deep, variable-length graph traversalRecursive joins degrade badly past a few hops

Recognizing it in an unseen problem

  • Before choosing a store, write the two or three highest-volume queries as literal key lookups — the key you need is usually the partition key, and it decides the store more than the store decides the key
  • "Transaction", "balance", "inventory", "booking", "must not double-charge" → relational with real ACID, or an explicit idempotency and reconciliation design; do not hand-wave this one
  • "Timeline", "feed", "events", "telemetry", "messages" with huge write volume and partition-scoped reads → wide-column on an LSM engine; expect the follow-up about hot partitions
  • If the prompt implies free-text search or analytics, propose a derived index fed from the source of truth, and be ready to explain how it stays in sync (change data capture, dual writes plus reconciliation, or a periodic rebuild)
  • Any time you propose denormalization, name the write amplification and the repair path in the same breath — proposing it without them is the most common way to lose the data-modelling signal
  • Distinguish "this table is a bottleneck" from "this database is a bottleneck": the first is solved with an index, a cache, or moving one table, and the first is far more often the truth
←previousVertical vs horizontal↑ CovernextCaching fundamentals→