Engineering Atlas › System Design Fundamentals

Trading duplication for read speed

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Why normalize in the first place

A normalized schema stores each fact once, an idea that goes back to Codd's relational model, which treated redundancy and the inconsistency it invites as problems for the schema to remove (Codd, 1970). Updates touch one row, and anomalies (two addresses for the same customer) cannot happen. The price is paid at read time: a product page may join six tables, and an aggregate like "followers" scans an index on every view.

What denormalizing changes

You move work from read time to write time. Instead of joining posts to users for the author name, you copy author_name into posts. Instead of COUNT(*) over comments, you keep comment_count on the post. The read becomes a single-row lookup. Each write now has a fan-out: renaming a user updates every post they wrote, and adding a comment increments a counter.

Keeping copies in step

There are three main strategies, in decreasing order of consistency.

  1. Same transaction. Update the source and copies atomically. Strong, but only works inside one database, and hot counters create lock contention.
  2. Change-driven async update. Write the source, then propagate via triggers, change data capture, or events. Readers see stale copies for milliseconds to seconds.
  3. Periodic rebuild. Recompute copies in batch. Simple and self-healing, but staleness is measured in minutes or hours.

Most real systems combine an async path for freshness with a periodic reconciliation job that detects and repairs drift. Facebook's TAO stores every two-way graph edge as two denormalized rows on two shards; it writes them without a shared transaction, so a crash can leave one side missing, and an asynchronous job repairs these "hanging" edges (Bronson et al., USENIX ATC 2013).

In NoSQL stores

Key-value and wide-column stores (DynamoDB, Cassandra) offer no joins, so denormalization is the default design method. You list the access patterns before designing anything, then shape items so each pattern is one query on a table or index, with data duplicated as needed. Current DynamoDB guidance no longer treats one single table as the only right answer: it puts entities that are read together in one table and splits ones with independent access patterns (DynamoDB docs).

Down the stack

Layer 1The contract

A denormalized design promises: the source table is authoritative; each copy equals the source within a stated staleness budget (zero for same-transaction updates, seconds for change-driven updates, hours for batch rebuilds); and any drift is detected and repaired within a stated window. Readers of a copy get no guarantee that two copies agree with each other at the same instant. If you cannot state these three numbers for a field, the design is not finished.

Layer 2The mechanism

Same-transaction copies use application code or a trigger, for example an AFTER UPDATE OF name ON users FOR EACH ROW trigger that runs UPDATE posts SET author_name = NEW.name WHERE author_id = NEW.id. Change-driven copies usually read the write-ahead log: Debezium attaches to a PostgreSQL logical replication slot (pgoutput plugin) and emits events with before, after, and the source LSN (Debezium docs). The consumer applies them conditionally, UPDATE posts SET author_name = $1, author_ver = $2 WHERE author_id = $3 AND author_ver < $2, so replays and reordered events cannot roll a copy backwards. Deletes need the same care: keep a versioned tombstone for a while, or a delayed older update can recreate a row the source already removed (Azure Architecture Center). Counters use atomic increments (SET like_count = like_count + 1, or DynamoDB UpdateItem with ADD), and hot counters split into N sub-rows chosen at random and summed on read. An increment is atomic but not idempotent: a client that retries after a timeout may count twice. Where that matters, use a conditional write or record the event id with the increment (DynamoDB docs).

Layer 3The machine

In PostgreSQL every UPDATE writes a new tuple version, not an in-place change. If no indexed column changes and the page has free space, it is a heap-only tuple (HOT) update; otherwise every index on the table gets a new entry too (Postgres docs). Index a denormalized column and its updates can never be HOT. Each version also produces a WAL record, typically 100 to 300 bytes, and the commit waits for an fsync of the WAL, roughly 50 µs to 1 ms on SSDs. A single hot counter row therefore serializes on its row lock and on commit latency, capping it at a few thousand increments per second before group commit helps. Renaming a prolific user with 200,000 posts writes 200,000 new tuples, around 40 MB of heap plus WAL that every replica must replay. On the read side, a page of 50 posts with the author embedded is one index range scan touching 2 or 3 pages, while the join version may touch 50 more pages in users, each a random read if not cached.

Why it's built this way

  1. Reads often outnumber writes 100 to 1 or more (system-design-primer) and joins cost random page reads, so precomputing the answer on write is cheaper overall.
  2. Every copy must change when the source changes, so writes get amplified by the fan-out.
  3. Updating copies in the same transaction holds locks across many rows, so large fan-outs move to an asynchronous path.
  4. Asynchronous paths can lose, duplicate, or reorder events, so updates carry versions and a reconciliation job repairs drift.
  5. One row cannot absorb unlimited writes, so hot aggregates are sharded or batched.

Diagram

sequenceDiagram
    participant App
    participant Users as Users source
    participant CDC as Change stream
    participant Posts as Posts copy
    App->>Users: UPDATE name = 'Ana B.'
    Users-->>CDC: change event
    CDC->>Posts: UPDATE author_name WHERE author_id = 42
    App->>Posts: SELECT post (no join)
    Note over Posts: stale until event applied

Worked example

A social feed renders 50 posts per page at 20,000 page views per second. Normalized, each page joins posts, users, and counts likes and comments: roughly 1 million row reads per second for counts alone, and P99 is 180 ms.

After denormalizing, each post row carries author_name, author_avatar, like_count, and comment_count. A page is one range read of 50 rows, and P99 drops to 15 ms. The write side changes:

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Fully normalizedOne source of truth, cheap writesJoins and aggregates at read timeWrite-heavy, or reads are already fast
Duplicated columnsRemoves joins on hot readsFan-out on update, driftSource rarely changes, read often
Precomputed aggregatesO(1) counts and sumsHot-row contention, driftCounts shown on every view
Separate read modelShaped exactly for the queryAnother store to run and rebuildRead and write shapes differ a lot
Cache insteadNo schema changeInvalidation, cold startsData is read-mostly and keyed simply

Pitfalls in production

Leadership lens

Check yourself

  1. Why do denormalized counters often need sharding while denormalized names do not?

    Show answer

    Counters are written on every event and concentrate writes on one row, while names change rarely, so their fan-out cost is occasional rather than continuous.

  2. What does a periodic reconciliation job buy you if the async path already works?

    Show answer

    It bounds the lifetime of drift caused by bugs, lost events, or manual fixes, which the async path cannot detect by itself.

  3. Why is denormalization the default in DynamoDB-style stores?

    Show answer

    They have no joins and charge per item read, so the only way to serve a query cheaply is to store data already shaped for it.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →