Denormalisation and Fan-Out

Keeping a second copy of a fact so reads are cheap, fanning out on write, and deciding which copies may be stale.

Syntaxwrite(source) + write(outbox) in one transaction; a worker reads the outbox and updates every copy

Denormalisation stores the same fact in more than one place so a read does not have to assemble it. The canonical case is a timeline. Kleppmann's Twitter example in Designing Data-Intensive Applications frames it as two options: fan-out on read, where posting is one insert and reading joins across everyone you follow; or fan-out on write, where posting inserts a row into every follower's materialised timeline and reading is a single ordered scan.

Choosing a direction

Read-heavy workloads favour fan-out on write, because the expensive work happens once per post instead of once per view, off the request path. The failure case is skew: an account with ten million followers turns one post into ten million writes. The published answer is a hybrid — fan out on write for ordinary accounts, and merge the small number of very-high-follower accounts in at read time.

Keeping the copies in step

A copy in the same database can be written in the source's transaction. A copy in another store cannot, so you need a record of intent that commits with the source: an outbox row, or change data capture off the replication log. Both turn "update two systems" into "update one system and replay", which is retryable, and therefore requires the consumer to be idempotent.

What may be stale

Decide this per field and write it down. Follower counts, like counts and ranking scores can lag seconds. Money, entitlements and permissions cannot — never read an authorisation decision from a denormalised copy.

Example

Example · javascript
// Fan-out on write, with the trigger for the fan-out committed atomically
// alongside the post itself.
async function createPost(db, authorId, body) {
  return db.tx(async (t) => {
    const post = await t.one(
      `INSERT INTO posts (author_id, body)
       VALUES ($1, $2) RETURNING id, created_at`,
      [authorId, body],
    );

    // The outbox row commits with the post. If the worker is down, the row is
    // still here; if the transaction rolls back, there is no orphan event.
    // This is the only way to get "both or neither" across two systems.
    await t.none(
      `INSERT INTO outbox (topic, payload) VALUES ($1, $2)`,
      ['post.created', { postId: post.id, authorId, createdAt: post.created_at }],
    );
    return post;
  });
}

const FANOUT_FOLLOWER_LIMIT = 100_000;

// Hybrid fan-out: materialise into followers' timelines for ordinary accounts,
// and leave very-high-follower accounts to be merged in at read time.
async function handlePostCreated(db, { postId, authorId, createdAt }) {
  // Destructuring in the parameter list; the longhand is
  //   async function handlePostCreated(db, event) { const postId = event.postId; ... }
  const { followers } = await db.one(
    'SELECT followers FROM user_stats WHERE user_id = $1',
    [authorId],
  );

  if (followers > FANOUT_FOLLOWER_LIMIT) return; // readers merge this author live

  // One statement, server-side: do not pull a million follower ids into Node.
  // ON CONFLICT makes the worker safe to retry after a partial failure.
  // created_at is the post's own timestamp from the event, never the worker's
  // clock: the read path merges these rows with live ones by created_at, so a
  // retried or delayed fan-out would otherwise order the post as if it were new.
  await db.none(
    `INSERT INTO timeline (user_id, post_id, created_at)
     SELECT f.follower_id, $1, $2 FROM follows f WHERE f.followee_id = $3
     ON CONFLICT (user_id, post_id) DO NOTHING`,
    [postId, createdAt, authorId],
  );
}

// The read side pays for the hybrid: materialised rows, plus a live query for
// the handful of accounts that were skipped on write.
async function readTimeline(db, userId, limit = 50) {
  const [materialised, live] = await Promise.all([
    db.manyOrNone(
      `SELECT post_id, created_at FROM timeline
       WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2`,
      [userId, limit],
    ),
    db.manyOrNone(
      `SELECT p.id AS post_id, p.created_at FROM posts p
       JOIN follows f ON f.followee_id = p.author_id
       JOIN user_stats s ON s.user_id = p.author_id
       WHERE f.follower_id = $1 AND s.followers > $2
       ORDER BY p.created_at DESC LIMIT $3`,
      [userId, FANOUT_FOLLOWER_LIMIT, limit],
    ),
  ]);

  return [...materialised, ...live]
    .sort((a, b) => b.created_at - a.created_at)
    .slice(0, limit);
}

When to use it

  • Deciding between materialising a timeline on write and assembling it on read for a feed product.
  • Keeping a cached or search-index copy of a row in step with the database without losing updates.
  • Adding a counter or ranking score that can lag, while keeping balances and permissions read from the source.
  • Handling an account whose follower count makes fan-out on write produce millions of writes per post.

More examples

A denormalised counter that may be stale, and the job that proves it

Shows the two halves of a stale-tolerant copy: relative updates so concurrent writers compose, and a reconciliation query whose mismatch count you alert on. Reach for it whenever a number is displayed rather than acted on — and never for a balance, a quota or a seat count, where the authoritative read must come from the source.

Example · sql
-- The copy: a count on the row that reads it, so rendering a profile is one
-- lookup rather than an aggregate over a 200M-row table.
ALTER TABLE user_stats ADD COLUMN followers BIGINT NOT NULL DEFAULT 0;

-- Maintained by the follow/unfollow consumer. Relative, not absolute: two
-- concurrent follows both land, where SET followers = 7 would lose one.
UPDATE user_stats SET followers = followers + 1 WHERE user_id = $1;

-- Reconciliation, run nightly on a replica. It does not silently repair:
-- it reports, because a nonzero drift means the consumer has a bug.
WITH truth AS (
  SELECT followee_id AS user_id, count(*) AS n FROM follows GROUP BY followee_id
)
SELECT s.user_id, s.followers AS stored, coalesce(t.n, 0) AS actual
FROM user_stats s
LEFT JOIN truth t ON t.user_id = s.user_id
WHERE s.followers <> coalesce(t.n, 0);

Fan-out on read, kept viable with a cache instead of a copy

The reminder that denormalisation is not the only tool: a cached assembly keeps one authoritative copy and buys read latency with a bounded staleness budget instead of permanent write complexity. Reach for this when the fan-out is small and bounded, and for fan-out on write only when it is large or unbounded.

Example · javascript
// Not every read pattern justifies materialising. If the fan-out is bounded —
// a project with at most a few dozen members, say — fan out on read and cache
// the assembled result, so there is exactly one copy to invalidate.
const TTL_SECONDS = 30;

async function projectDashboard(redis, db, projectId) {
  const key = `dashboard:v3:${projectId}`; // version in the key: a shape change
                                          // retires every old entry at once
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  // Assembled live from normalised tables. One source of truth, no drift.
  const [project, members, openIssues] = await Promise.all([
    db.one('SELECT id, name, created_at FROM projects WHERE id = $1', [projectId]),
    db.manyOrNone(
      `SELECT u.id, u.display_name FROM memberships m
       JOIN users u ON u.id = m.user_id WHERE m.project_id = $1`,
      [projectId],
    ),
    db.one(
      `SELECT count(*)::int AS n FROM issues
       WHERE project_id = $1 AND state = 'open'`,
      [projectId],
    ),
  ]);

  const view = { project, members, openIssues: openIssues.n };
  // A short TTL is a staleness budget you can state in one number. It is much
  // easier to reason about than N denormalised copies with N update paths.
  await redis.set(key, JSON.stringify(view), 'EX', TTL_SECONDS);
  return view;
}

Discussion

  • Be the first to comment on this lesson.