Skip to content

9.7.18 — The Community Platform Family: Stack Overflow, LinkedIn, Social Network

"Design Stack Overflow." "Design LinkedIn." "Design a social network."

Three prompts that produce nearly the same first ten minutes: people, things they post, relationships between them, and a rule deciding what appears at the top. Then they diverge sharply, and the divergence is entirely explained by two questions you can ask in the first two minutes.

This page builds the shared skeleton, asks the two questions, and then works each platform's real difference properly.

1. The two questions that separate them

ranked byQUALITYranked byWHO YOU KNOWrelationship is ONE-WAYrelationship is MUTUALSTACK OVERFLOWvotes decide, not friendsreputation is the currencyLINKEDINconnection needs consentdegrees of separationSOCIAL NETWORKfollow, no consent neededtimeline is a query over edgesFRIEND-BASED NETWORKmutual friendshipprivacy is the hard partTwo questions place any community prompt, and the position tells you the edge model and the ranking model.
Figure 1 — Two questions decide the design. Does a relationship need the other person's consent? And is content ranked by quality or by who you are connected to? Ask both before writing anything.

Question one: does a relationship need consent?

A follow is one-way and instant. I follow you, you need not do anything, and there is one row.

A connection is mutual and requires agreement. That is not one row with a flag — it is a small state machine with an invitation, an acceptance, a rejection, a withdrawal and a block, and section 4 is about how much of the design that one difference drags in.

Question two: what decides the order of what you see?

Quality, judged by the crowd — votes, acceptance, reputation. The best answer to a question is the best answer for everybody, so ranking is global and cacheable.

Who you are connected to — a timeline assembled per person from the people they follow. Ranking is personal, nothing is shared between users, and the cost profile is completely different.

Stack Overflow is one-way plus quality. LinkedIn is mutual plus connection-based. A public social network is one-way plus connection-based. A friend-based network is mutual plus connection-based, and its hard part turns out to be privacy rather than ranking.

2. The shared skeleton

Everything in this family is built from four pieces.

PieceWhat it is
ActorA person or organisation that acts
ContentA post, question, answer, comment
EdgeA relationship between two actors
SignalA vote, like, view, or report

Content is one table with a type, not one table per kind. A question, an answer and a comment share almost everything — an author, a body, timestamps, an edit history, votes, a moderation state — and differ in a couple of fields and in what they may attach to.

typescript
interface Post {
  id: PostId;
  kind: "question" | "answer" | "comment";   // (1)
  parentId: PostId | null;                   // (2)
  authorId: ActorId;
  body: string;
  createdAt: Instant;
  score: number;                             // (3) cached, see section 3
  state: PostState;                           // (4)
}

(1) The kind is a field. Three tables would triple every query that needs "everything this person wrote" and every feature that applies to all of them — reporting, editing, deletion, moderation.

(2) An answer's parent is a question; a comment's parent is either. One nullable field expresses the whole structure, and the rules about which kind may parent which are a small check rather than a schema.

(3) A cached aggregate. The votes are the truth, and section 3 is about why the cache must exist and how it stays honest.

(4) Visible, deleted, locked, under review. A state rather than a set of booleans, so "deleted and also featured" cannot be represented.

An edge is a row with a type, and the storage question is where the first real decision appears:

typescript
interface Edge {
  fromId: ActorId;
  toId: ActorId;
  type: "follows" | "connected" | "blocks" | "watches";
  createdAt: Instant;
}

For a one-way follow, one row is exactly right. "Who do I follow" is a query on fromId, "who follows me" is a query on toId, and both need an index.

For a mutual connection, store two rows, one in each direction, written in the same transaction. It looks redundant and it is the right call, because every query in the product is "who is connected to this person", and a single-row representation makes that query search two columns and merge the results — on every profile view, forever. Two rows make it one indexed lookup. Say the redundancy is deliberate and name what it costs: both rows must be written and removed together, which is one transaction.

3. Stack Overflow: votes are events, score is a cache

The centre of a quality-ranked platform is that votes must be counted correctly and read constantly, and those two requirements pull in opposite directions.

Each vote is a row, and one person may vote once per post:

typescript
interface Vote {
  postId: PostId;
  voterId: ActorId;
  value: 1 | -1;                    // (1)
  createdAt: Instant;
}
// UNIQUE INDEX ON (postId, voterId)  (2)

(1) A signed number rather than two tables or a boolean, so changing an upvote to a downvote is one update.

(2) The unique index is the whole correctness story. Without it, a double-clicked button or a retried request votes twice, and the count is quietly wrong forever. With it, the second attempt fails at the database and the application turns that into "you already voted" — the same conditional-claim shape as every other race in this chapter, and the same reason the wallet uses a unique index on its idempotency key (9.7.10).

Changing a vote is an update, not a delete and an insert. Upvote then downvote should move the score by two, and doing it as two operations means a moment where the vote does not exist, which a concurrent recount will see.

The score must be cached on the post, and this deserves defending because it contradicts the rule from the coffee machine, where a derived count was never stored.

The difference is the read-to-write ratio and the cost of the calculation. There, canMake was five map lookups on data already in memory. Here, "count every vote on this post" means scanning a vote table with billions of rows, and it happens on every page view of every question in a list of thirty. Counting on read is not viable, so the score is stored, incremented in the same transaction as the vote, and repaired by a periodic recount that compares the stored score against the actual votes.

That last part is what keeps it honest and it is the part candidates leave out. State it as a rule: a cached aggregate needs an owner, a repair job and an alert on divergence. Without those three it is not a cache, it is a second truth that will drift.

Reputation is the same shape one level up. It is an aggregate over events — your answer was upvoted, accepted, your question was downvoted, you were awarded a bounty — and it is read on every single page because it sits next to your name.

typescript
interface ReputationEvent {
  actorId: ActorId;
  reason: "answerUpvoted" | "answerAccepted" | "questionDownvoted" | "bounty";
  delta: number;
  sourcePostId: PostId;             // (1)
  createdAt: Instant;
}

(1) Every reputation change points at what caused it. That is what makes the history explainable — "why did I lose two points" is a query, not a mystery — and it is what makes reversal correct: an upvote being withdrawn produces a compensating event, and the original stays in the log. This is exactly the ledger discipline from the wallet: record what happened, derive the total, never edit history.

Two rules that come from the domain rather than from the data model, and volunteering them shows you looked at the product:

Reputation change from votes is capped per day, because otherwise a coordinated group can manufacture standing overnight.

A vote is reversible for a short window and then locked. This is why revenge-voting sweeps have to be detected rather than simply undone, and it is a good example of a rule that exists for social reasons and lands squarely in the data model.

Accepting an answer is a property of the question, not of the answer. One accepted answer per question, so it is a nullable acceptedAnswerId on the question rather than an isAccepted flag on each answer. The flag version allows two accepted answers to exist, and then someone has to write code that reconciles them. Making the wrong state unrepresentable beats checking for it.

A connection needs both people to agree, and that single requirement is the difference between one row and a lifecycle.

typescript
type InvitationState =
  | { kind: "pending"; sentAt: Instant }
  | { kind: "accepted"; at: Instant }
  | { kind: "declined"; at: Instant }        // (1)
  | { kind: "withdrawn"; at: Instant }
  | { kind: "expired" };                     // (2)

(1) Declining is deliberately quiet. The sender is not told, because a product that reports rejections teaches people not to send invitations. That is a product decision with a direct modelling consequence: the state exists and is not exposed.

(2) Invitations expire. Without expiry, an inbox accumulates thousands of invitations from years ago and the feature becomes unusable.

The race that makes this question worth asking: both people invite each other at the same time. Two pending invitations now exist between the same pair, and accepting one leaves the other dangling.

The fix is to normalise the pair before storing it:

typescript
function pairKey(a: ActorId, b: ActorId): string {
  return a < b ? `${a}:${b}` : `${b}:${a}`;   // (1)
}
// UNIQUE INDEX ON (pairKey) WHERE state = 'pending'   (2)

(1) Sort the two identifiers so that the pair has one single agreed key regardless of who invited whom.

(2) A unique index on that key for pending invitations means the second simultaneous invitation cannot be created. The application catches the failure and does the sensible thing — treats the second invite as an acceptance of the first, which is exactly what both people wanted anyway.

This trick — sort the pair, index the sorted key — is worth remembering because it appears everywhere two-party relationships exist: friend requests, direct message threads, matches. Without it you write reconciliation code forever.

Degrees of separation is the other LinkedIn-specific piece, and the right answer starts by refusing to compute it globally. "How is this person connected to me" is a shortest path in a graph of a billion people, and running that on every profile view is not viable.

What works is a bounded bidirectional search. Expand from both ends at once — my connections, their connections — and stop as soon as the two frontiers meet or as soon as you have gone three steps. It works because of the arithmetic: with an average of 500 connections, one step from each side covers 500 people each, and their intersection almost always answers "second degree". Searching from one side alone to depth three would mean 500³, which is 125 million people; from both sides it is two sets of 500 and one intersection.

And the cap is the design decision, not a shortcut. Beyond three degrees the answer stops being meaningful to a human, so the product declares "3rd+" instead of computing a real distance. Choosing to answer a cheaper question is often the best engineering available, and saying so deliberately is much stronger than implementing a graph traversal you cannot afford.

5. Social network: the timeline is a query, until it is not

With one-way follows and content ranked by who you follow, the core read is: give me recent posts from everyone I follow.

As a query it is simple and it is the right starting point:

sql
SELECT p.* FROM posts p
  JOIN edges e ON e.toId = p.authorId
 WHERE e.fromId = :me AND e.type = 'follows'
   AND p.createdAt > :cursor
 ORDER BY p.createdAt DESC
 LIMIT 50;

This works well up to a point and then stops, and the LLD-level thing to know is where it stops and what the two escapes are.

Escape one: build each person's timeline when they post — write the new post's identifier into a list for every follower. Reads become one lookup of a prepared list, which is why timelines feel instant. Writes become expensive in proportion to follower count.

Escape two: keep computing on read for people who follow very few accounts, since the query above is cheap for them.

The famous problem is the account with ten million followers, where writing to ten million lists for one post is absurd. The standard resolution is a hybrid: ordinary accounts are written out to their followers' lists, very large accounts are not, and a reader's timeline is their prepared list merged with a live query for the few huge accounts they follow. The full treatment with numbers is 11.8; at LLD level, knowing that the choice exists and what decides it is what is being graded.

Blocking is where the model gets subtle, and it is the detail worth volunteering. A block is a one-way edge — I block you — but its effects are two-way: you cannot see my posts, I do not see yours, neither of us appears in the other's search results, and any existing follow edges are removed in both directions. So a one-way relationship produces symmetric consequences, which is exactly the kind of thing that gets implemented in six places and forgotten in a seventh.

The answer is one visibility function that everything calls:

typescript
function canSee(viewer: ActorId, post: Post, edges: EdgeReader): boolean {
  if (edges.blockEitherWay(viewer, post.authorId)) return false;   // (1)
  if (post.state.kind !== "visible") return viewer === post.authorId;  // (2)
  switch (post.audience.kind) {
    case "public":     return true;
    case "followers":  return edges.follows(viewer, post.authorId);
    case "connections":return edges.connected(viewer, post.authorId);  // (3)
    case "private":    return viewer === post.authorId;
  }
}

(1) Blocking is checked first and in both directions, so no later rule can accidentally grant visibility.

(2) A deleted or hidden post remains visible to its author, which is what people expect and what stops a moderation action looking like data loss.

(3) The audience list is a small union, so adding "my connections' connections" is a case rather than a rewrite.

One function, called by the timeline, by search, by the profile page, by notifications and by the API. The moment there are two implementations of this rule, one of them is wrong, and the way you find out is a support ticket about a private post appearing in someone's search results.

6. What is genuinely shared: moderation and the report

All three platforms need the same thing and candidates almost never mention it.

A report is content about content. It has a reporter, a target, a reason, and its own lifecycle — new, reviewed, actioned, dismissed. It is not a flag on the post, because the same post can be reported by forty people for six different reasons, and the moderator needs to see all of them.

Moderation actions are events with an actor. Who hid this, when, and why. This is the audit trail that makes a decision reversible and explainable, and it is the same event-log discipline as reputation and as the task system's activity (9.7.17).

And the automatic action needs a threshold with a floor. Enough reports hides a post pending review, which is a rule that is trivially abused if the threshold is a plain count — a coordinated group of ten can silence anybody. Weighting reports by the reporter's standing, and requiring reports from independent accounts, is the standard defence. Naming the abuse before naming the mechanism is what makes this sound like experience rather than a feature list.

7. What the interviewer will push on

"Are questions, answers and comments three tables?" No — one content table with a kind and a nullable parentId. They share an author, a body, an edit history, votes and a moderation state, and every cross-cutting feature would otherwise be written three times. The rules about which kind may parent which are a check, not a schema.

"How do you stop someone voting twice?" A unique index on (postId, voterId). Not an application check, because two concurrent requests both pass an application check. Changing a vote is an update of the existing row's value, not a delete and an insert, because the gap between them is visible to a concurrent recount.

"Do you store the score or compute it?" Store it, and defend the exception. The rule elsewhere is to derive aggregates, and here the read-to-write ratio and the size of the vote table make counting on read impossible. Then give the three things that make a cached aggregate legitimate: it is updated in the same transaction as the event, a periodic job recounts and repairs it, and divergence raises an alert. Reputation is the same shape, built from an event log where every entry points at the post that caused it.

"Two people send each other an invitation at the same instant." Normalise the pair — sort the two identifiers into one key — and put a unique index on that key for pending invitations. The second insert fails, and the application treats it as an acceptance of the first, which is what both people wanted. Volunteering that this trick applies to any two-party relationship, including message threads and matches, is the extra step.

"How do you compute second-degree connections?" Bounded bidirectional search, capped at three. With 500 connections on average, expanding one step from each side is two sets of 500 and an intersection, where a one-sided search to depth three would be 125 million. And the cap is a product decision: beyond three the answer means nothing to a human, so the product says "3rd+" rather than computing a real distance.

"Someone blocks someone else. What happens?" The edge is one-way, the effects are symmetric — neither sees the other's content, neither appears in the other's search, and existing follows are removed in both directions. Then the design point: this must live in one visibility function that the timeline, search, profiles, notifications and the API all call, because the second implementation of a visibility rule is always the one with the bug.

The thing to volunteer that nobody asks for: reports and moderation. A report is content about content with its own lifecycle, not a flag on the post, and automatic hiding needs weighting by reporter standing rather than a plain count — otherwise ten coordinated accounts can silence anyone. Every candidate designs the posting and the reading; the platforms that exist all spend more effort on what happens when people misuse them.

Recall

  • Two questions place any community prompt: does the relationship need consent, and is content ranked by quality or by who you are connected to?
  • One content table with a kind and a nullable parentId. Three tables triple every cross-cutting feature.
  • One-way follow = one row. Mutual connection = two rows written in one transaction, because every product query is "who is connected to this person" and one row makes that search two columns.
  • A vote is a row with a unique index on (postId, voterId). Changing a vote updates the row; it never deletes and re-inserts.
  • The score is a cached aggregate, and that is a defended exception. A cached aggregate is only legitimate with three things: written in the same transaction, repaired by a recount job, and alerted on divergence.
  • Reputation is an event log where every entry points at the post that caused it. Reversal is a compensating entry, never an edit.
  • Accepted answer is a field on the question, not a flag on each answer — two accepted answers then cannot exist.
  • Consent turns an edge into a state machine: pending, accepted, declined quietly, withdrawn, expired. Normalise the pair and index the sorted key so simultaneous invitations cannot both exist.
  • Degrees of separation: bounded bidirectional search capped at three. Two sets of 500 and an intersection, not 500³.
  • A block is one-way with symmetric effects, and all of it lives in one visibility function every reader calls.
  • A report is content about content with its own lifecycle, and auto-hiding must weight reporters or ten accounts can silence anyone.

Self-test: What two questions separate these platforms? Why is a mutual connection two rows? What makes a cached score legitimate rather than a second truth? What stops two simultaneous invitations? Why is the degree search capped at three? Where does the block rule live?

Quiz Bank

FoundationalModel the content and the relationships for Stack Overflow, LinkedIn and a follow-based social network, and show where the three designs diverge.

One content table, not one per kind. A question, an answer and a comment share an author, a body, timestamps, an edit history, votes and a moderation state. They differ in a kind field and in what they may attach to:

typescript
interface Post {
  id: PostId;
  kind: "question" | "answer" | "comment";
  parentId: PostId | null;
  authorId: ActorId;
  body: string;
  score: number;
  state: PostState;
}

Three tables would mean writing "everything this person wrote", editing, reporting, deletion and moderation three times each, and every new cross-cutting feature three more times.

One edge table, with the storage decision made per relationship type.

A follow is one-way, so one row. "Who I follow" is an index on the source, "who follows me" is an index on the target.

A connection is mutual, so two rows, written in one transaction. The redundancy is deliberate: every query the product runs is "who is connected to this person", and a single-row representation makes that search two columns and merge results on every profile view. Two rows make it one indexed lookup, and the cost is that both must be written and removed together.

Then the two questions that separate the three platforms.

Does the relationship need consent? A follow does not, so it is an instant row. A connection does, so it is an invitation with a lifecycle — pending, accepted, declined, withdrawn, expired — and the whole of section 4's machinery follows from that one word.

What decides the order of what you see? On Stack Overflow it is quality judged by votes, so ranking is global: the best answer is the best answer for everybody, which means it can be computed once and cached for all readers. On a social network it is who you follow, so ranking is per person, nothing is shared between users, and the cost profile is entirely different.

Where each one diverges in practice.

Stack Overflow — the interesting machinery is votes, scores and reputation: a unique index per voter per post, a cached score with a repair job, and reputation as an event log.

LinkedIn — the interesting machinery is consent and the graph: an invitation state machine with a normalised pair key, and degrees of separation as a bounded bidirectional search.

Social network — the interesting machinery is the timeline: a query over follow edges that eventually becomes a prepared list per reader, with the enormous-account case handled by merging a live query into it.

The reason the framing is worth having: an unseen prompt like "design a book review site" is answered by the same two questions. Relationships need no consent, ranking is by quality, so it is the Stack Overflow shape, and you already know where the difficulty is.

AppliedDesign voting and reputation. Cover the double-vote race, changing a vote, and how the score stays correct.

A vote is a row, and the correctness lives in an index:

typescript
interface Vote { postId: PostId; voterId: ActorId; value: 1 | -1; createdAt: Instant; }
// UNIQUE INDEX ON (postId, voterId)

The double-vote race is stopped by the database, not by the application. Two concurrent requests both read "no existing vote", both pass an application check, and both insert. The unique index means the second insert fails, and the application turns that failure into "you already voted". This is the same conditional-claim shape as every other race in this chapter: the write decides, and code above it reacts.

Changing a vote is an update of value, not a delete followed by an insert. Between the delete and the insert the vote does not exist, and a concurrent recount will see a score that never should have existed. One update moves the score by two and has no intermediate state.

The score is cached on the post, and the exception has to be defended because the general rule in this book is to derive aggregates rather than store them. The reason to break it here is the read-to-write ratio: counting votes means scanning a table with billions of rows, and it would happen thirty times per question-list page view. Deriving on read is simply not available.

So the cached score gets the three things that make a cache legitimate rather than a second truth:

It is written in the same transaction as the vote, so it cannot be missed when the vote succeeds.

A periodic job recounts and repairs it, comparing stored scores against actual votes.

Divergence raises an alert rather than being silently repaired. A score that drifted by one is a bug that will drift by a hundred, and repairing without alerting hides the cause forever.

Reputation is the same shape one level up, and it is an event log:

typescript
interface ReputationEvent {
  actorId: ActorId;
  reason: "answerUpvoted" | "answerAccepted" | "questionDownvoted" | "bounty";
  delta: number;
  sourcePostId: PostId;
  createdAt: Instant;
}

Every entry points at what caused it, so "why did I lose two points" is a query rather than a mystery. A withdrawn upvote produces a compensating entry; the original is never edited. That is the ledger discipline from the wallet, and it is what makes the total explainable and rebuildable.

Two domain rules worth volunteering, because they are where the model meets reality:

Reputation from votes is capped per day, or a coordinated group manufactures standing overnight.

Votes are reversible for a short window and then locked, which is why revenge-voting has to be detected and swept rather than simply undone, and why the sweep itself is a set of compensating events rather than a deletion.

And the modelling detail people get wrong: the accepted answer is a nullable field on the question, not a flag on each answer. The flag version allows two accepted answers to exist at once, which then requires code to reconcile them. Making the wrong state unrepresentable is always cheaper than checking for it.

InterviewTwo people click 'connect' on each other at the same moment. What does your system do?

Name what goes wrong without a design. Both inserts succeed, so there are two pending invitations between the same pair. Person A accepts theirs and is now connected. Person B still sees a pending invitation to a person they are already connected to. Accepting it either creates a duplicate connection or fails with an error that makes no sense to the user. Then someone writes a nightly job to clean up dangling invitations, and that job becomes permanent.

The fix is to make the pair a single key.

typescript
function pairKey(a: ActorId, b: ActorId): string {
  return a < b ? `${a}:${b}` : `${b}:${a}`;
}

Sorting the two identifiers means the pair has one single agreed form regardless of who initiated. Then a unique index on that key, restricted to pending invitations, makes the second simultaneous insert impossible.

What the application does with the failure is the good part. It does not show an error. It recognises that a pending invitation already exists from the other person and treats the second click as an acceptance, which is precisely what both people intended. The race resolves into the outcome they both wanted, with no cleanup job and no confusing message.

Why the index is restricted to pending. The pair will legitimately have other invitations over time — declined, withdrawn, then a new one years later. Only one pending invitation may exist at a time, so the uniqueness applies to that state alone. Getting this wrong in the other direction makes it impossible ever to reconnect after a disconnection, which is a much worse bug than the one being fixed.

The connection itself is then two rows, written in the same transaction as the invitation moving to accepted. Two rows because every query in the product asks "who is connected to this person", and one row makes that a two-column search on every profile view.

The generalisation is what makes this answer land: sort the pair, index the sorted key. It applies to friend requests, to direct message threads — where the alternative is duplicate conversations between the same two people — and to any matching system. Any time a relationship is defined by an unordered pair, the unordered pair needs one agreed form or the database cannot enforce anything about it.

One more state worth mentioning unprompted. Declining is quiet: the sender is never told. That is a product decision — a system that reports rejections teaches people to stop sending invitations — and it shows up in the model as a state that exists and is deliberately not exposed. Invitations also expire, because without expiry an inbox fills with requests from years ago and the whole feature stops being used.

StaffA private post appears in someone's search results. Walk through why this happens in real systems and how you design so it cannot.

The cause is almost never a wrong rule. It is a second implementation of the right rule. Visibility is checked in the timeline, in search, on the profile page, in notifications, in the public API, in the embed preview, in the email digest and in the mobile client's cache. Each was written by a different person at a different time, and the rule changed twice since the earliest one. The oldest path does not know about blocking, or the newest audience type, or that a deleted post stays visible to its author.

So the design requirement is not "check visibility"; it is "there is exactly one place where visibility is decided".

typescript
function canSee(viewer: ActorId, post: Post, edges: EdgeReader): boolean {
  if (edges.blockEitherWay(viewer, post.authorId)) return false;
  if (post.state.kind !== "visible") return viewer === post.authorId;
  switch (post.audience.kind) {
    case "public":      return true;
    case "followers":   return edges.follows(viewer, post.authorId);
    case "connections": return edges.connected(viewer, post.authorId);
    case "private":     return viewer === post.authorId;
  }
}

Blocking is first and both ways, so no later rule can accidentally grant access. The audience is a union, so a new audience type is a case the compiler will demand everywhere it matters (9.3.6).

But a single function is not sufficient at scale, and this is the part the question is really about. Search cannot call canSee per result — filtering a million documents one call at a time is not a system. So the rule has to exist in two forms, and the design problem is keeping them in step.

The workable answer is to push the audience into the index as data. Every indexed document carries the identity of who may see it — public, or the author's identifier for follower-restricted content — and the query filters on that. The query is then constructed by the same code that owns the rule, so there is one definition even though there are two executions.

And then a final check on the way out. Search returns candidates; the response layer runs canSee on the page of results actually being returned — fifty items, not a million. This is the same shape as search-is-a-hint-and-the-claim-is-the-truth from the booking family: the cheap filter narrows, the authoritative check confirms, and the cost is bounded by the page size rather than by the corpus.

Three failure modes to design for explicitly.

The index is stale. Someone changes a post from public to private and the index has not caught up. The final check catches it, which is exactly why the final check exists rather than being redundant.

Blocking is applied after indexing. Blocks are per-viewer and cannot be baked into a shared index at all, so they must be applied at query time or at the final check. This is the most common real leak.

Caches upstream. A page cached for one user and served to another defeats every check below it. Any response containing content whose visibility depends on the viewer must be keyed by viewer or not cached at all, and this is worth stating because it is the one that bypasses all the careful work below it.

What I would monitor, since this class of bug is invisible until it is a headline: the rate of items removed by the final check, which measures how stale the index has become, with any sudden rise treated as an incident rather than as noise; and a periodic sampled comparison of what the index would return against what canSee allows, run as a background audit. The alternative is finding out from a user, and by then it has already happened many times.

The single sentence to leave the interviewer with: the rule must have one definition, however many places execute it, and every execution that is not the definition must be followed by one that is.

Flashcards

FlashThe two classifying questions

Does the relationship need consent — follow versus connection? And is content ranked by quality or by who you are connected to? Position on those two axes gives you the edge model and the ranking model.

FlashOne row or two

A one-way follow is one row. A mutual connection is two rows in one transaction, because every product query asks "who is connected to this person" and one row makes that a two-column search.

FlashStopping a double vote

A unique index on (postId, voterId). Application checks lose the race. Changing a vote updates the row's value — deleting and re-inserting leaves a gap a recount can see.

FlashWhen a cached aggregate is legitimate

Three things: written in the same transaction as the event, repaired by a periodic recount, and alerting on divergence. Without all three it is a second truth that will drift.

FlashSimultaneous invitations

Sort the two identifiers into one pair key and put a unique index on it for pending invitations. The loser of the race is treated as an acceptance — which is what both people wanted.

FlashDegrees of separation

Bounded bidirectional search capped at three. Two sets of ~500 and an intersection, not 500³. Beyond three the answer means nothing to a human, so the product says "3rd+".

Next: 9.7.19 — a publish-subscribe broker, where the whole design is decided by who remembers how far each reader has got.