Designing a Q&A Forum (Quora / StackOverflow)

Difficulty: Intermediate Prerequisites:Caching, Database Indexing, and Fan-Out


TL;DR

A Q&A forum lets users post questions, write answers, and vote on both. Reads dominate writes 100:1, and most visitors arrive cold from a search engine to read one question and its top answers. The hard parts: serving that read volume cheaply, ranking answers so the best one lands first, and full-text search over 10M questions.

The shape of the answer: Postgres as the source of truth, Redis in front of the read path, Elasticsearch beside it for search, and rankings computed asynchronously rather than per request.


Understanding the Problem

A Q&A forum lets users post questions, write answers, and vote on both. Readers vastly outnumber writers β€” most traffic is people reading a question and its top answers, often arriving from a search engine. The hard parts: serving reads cheaply at scale (100:1 read-to-write ratio), ranking answers so the best one appears first, and providing full-text search across millions of questions.

The read-heavy skew is the single most important fact about this system. It means a cache is not an optimisation you add later β€” it is load-bearing from day one. It also means the write path can afford to be slower and do more work (recompute rankings, fan out to a search index) because writes are 1% of traffic.


Naive First Cut

flowchart LR
    USER["User"]:::client
    API["Monolith API"]:::service
    DB[("Single SQL DB")]:::data

    USER --> API
    API --> DB

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

Why this breaks:

The rest of the doc evolves this into a design that survives a front-page traffic spike.


Prior Art We’re Drawing From


Functional Requirements

Core (top 3)

  1. Post and read Q&A β€” users post questions, others write answers, anyone can read
  2. Vote on answers β€” upvote/downvote; answers ranked by score
  3. Search questions β€” full-text search across all questions by keyword and tags

Below the Line


Non-Functional Requirements

Below the Line


Scale Estimation (Back-of-Envelope)

The numbers matter because they tell you what not to build. 100GB and 500 writes/sec is a single-primary Postgres workload. Proposing a sharded multi-region datastore here is over-engineering, and a good interviewer will push back on it.


Core Entities


API

POST /v1/questions
  Body: { title, body, tags: ["system-design", "caching"] }
  Response: { questionId, slug }

POST /v1/questions/{questionId}/answers
  Body: { body }
  Response: { answerId }

GET /v1/search?q=distributed+caching&tags=system-design&page=1
  Response: { results: [{ questionId, title, score, answerCount }], total }

High-Level Design

FR1: Post and Read Q&A

The Question Service writes to Postgres. For reads, a Redis cache sits in front β€” popular question pages are cached with a short TTL.

flowchart LR
    USER["User"]:::client
    API["Q&A Service"]:::service
    CACHE[("Redis<br/>page cache")]:::data
    DB[("Postgres")]:::data

    USER -->|"1. GET question page"| API
    API -->|"2. Lookup cached page"| CACHE
    CACHE -.->|miss| DB

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

FR2: Vote on Answers

Votes update the answer’s score. The write goes to Postgres; the cached question page is invalidated so the next read fetches fresh rankings.

flowchart LR
    USER["User"]:::client
    API["Q&A Service"]:::service
    DB[("Postgres")]:::data
    CACHE[("Redis")]:::data

    USER -->|"1. POST upvote or downvote"| API
    API -->|"2. Write vote to DB"| DB
    API -->|"3. Invalidate page cache"| CACHE

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0

FR3: Search Questions

An Elasticsearch index stores questions with full-text fields. Writes to Postgres are synced to ES via async pipeline.

flowchart LR
    USER["User"]:::client
    API["Q&A Service"]:::service
    ES[("Elasticsearch")]:::data
    DB[("Postgres")]:::data
    KAFKA["Kafka"]:::async

    USER -->|"1. Search questions"| API
    API -->|"2. Full-text query"| ES
    DB -->|"3. Change events"| KAFKA
    KAFKA -->|"4. Sync to search index"| ES

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef async fill:#3a2a4c,stroke:#c084fc,color:#e2e8f0

Deep Dives

Deep Dive 1: Handling viral questions (thundering herd)

Bad: A question goes viral β€” 100K users hit the page simultaneously. Cache TTL expires, all 100K requests hit the DB at once (thundering herd). DB overloads and the whole site slows down.

Good: Use a cache-aside pattern with a short TTL (30s). On cache miss, only one request fetches from DB (using a distributed lock or single-flight pattern). All other concurrent requests wait and receive the cached result once the first one completes.

Great: For known hot content (trending questions, front-page posts), use a β€œcache warming” strategy. A background job identifies trending questions (by view velocity) and proactively refreshes their cache before TTL expiry. This eliminates cache misses entirely for the hottest content. Combined with read replicas on Postgres for the long-tail of unpopular questions, the primary DB only handles writes.


Deep Dive 2: Answer ranking that rewards quality

Bad: Sort by raw vote count. Old answers accumulate votes over time and always stay on top, even when a newer, better answer exists.

Good: Use a time-decayed scoring formula: score = votes / (hours_since_posted + 2)^gravity. Newer answers with fewer votes can outrank older answers with more votes. This encourages fresh contributions.

Great: Combine vote score with engagement signals: answer acceptance (by the question author), view-time (do readers scroll past it or stop?), and author reputation. Weight these into a composite ranking score. Compute rankings asynchronously (not on every page load) and cache the sorted result. Re-rank every few minutes or on significant vote changes (>5 new votes). This keeps page loads fast while providing meaningful rankings.


Deep Dive 3: Full-text search with relevance

Bad: SQL LIKE '%distributed caching%' β€” no index can help, scans all 10M rows, returns results without relevance ranking.

Good: Elasticsearch index with standard BM25 scoring. Questions are indexed by title, body, and tags. Search returns results ranked by text relevance. Sync from Postgres to ES via Kafka (CDC pipeline) with <10s lag.

Great: Boost search relevance with engagement signals. Weight title matches higher than body matches. Boost questions with more views, votes, and accepted answers. Add tag-based filtering (faceted search). For β€œunanswered” queries, filter to questions with zero accepted answers β€” useful for contributors looking to help. Combined with search-as-you-type (prefix completion on the title field), users find answers before even finishing their question.


Design Self-Audit

Question Answer
Do we need sharding? No. ~100GB and ~500 writes/sec fits one Postgres primary. Scale reads with replicas and Redis. Say this explicitly β€” resisting unnecessary sharding is a signal, not a gap.
Redis goes down? Reads fall through to Postgres read replicas. Latency rises and the primary must be shielded by single-flight, but the site stays up. This is why the cache sits in front of replicas, not the primary.
Elasticsearch goes down? Search degrades to a Postgres full-text query (tsvector) with worse relevance, or returns β€œsearch temporarily unavailable.” Browsing and reading are unaffected β€” search is a separate failure domain.
Stale vote counts? Acceptable. A vote landing in the cached page seconds late costs nothing. We invalidate on write and accept a short window of staleness.
Cache invalidation on every vote? On a viral question that means constant invalidation, defeating the cache. Fix: don’t invalidate on every vote β€” let the short TTL expire naturally, and only invalidate on answer add/edit. Vote counts are the one field allowed to be slightly stale.
Vote fraud? Unique constraint on (user_id, target_id) makes double-voting impossible at the DB level. Ring-voting detection is an offline analytics job, not a request-path concern.

Final Architecture

flowchart LR
    USER["User"]:::client
    CDN["CDN"]:::edge
    API["Q&A Service"]:::service
    CACHE[("Redis<br/>page cache")]:::data
    DB[("Postgres<br/>primary")]:::data
    REPLICA[("Read Replicas")]:::data
    KAFKA["Kafka<br/>change events"]:::async
    ES[("Elasticsearch")]:::data
    RANK["Ranking Worker"]:::async

    USER -->|"Read question page"| CDN
    CDN -->|"Forward on miss"| API
    API -->|"Check page cache"| CACHE
    CACHE -.->|"miss"| REPLICA
    USER -->|"Post or vote"| API
    API -->|"Write"| DB
    DB -->|"Replicate"| REPLICA
    DB -->|"CDC stream"| KAFKA
    KAFKA -->|"Sync index"| ES
    KAFKA -->|"Recompute scores"| RANK
    RANK -->|"Write sorted rankings"| CACHE
    USER -->|"Search"| API
    API -->|"Full-text query"| ES

    classDef client fill:#4c3a5e,stroke:#818cf8,color:#e2e8f0
    classDef edge fill:#1e3a5f,stroke:#60a5fa,color:#e2e8f0
    classDef service fill:#1a3a2a,stroke:#4ade80,color:#e2e8f0
    classDef data fill:#3b3520,stroke:#fbbf24,color:#e2e8f0
    classDef async fill:#3b1f5e,stroke:#c084fc,color:#e2e8f0
Color Meaning
🟣 Purple Clients
🟒 Green Services
🟑 Yellow Data stores
πŸ”΅ Blue Edge / CDN

How it works end-to-end (read path β€” 99% of traffic):

  1. User requests a question page β€” usually arriving from a search engine, so the request is cold for that user but hot for the system
  2. CDN serves anonymous traffic β€” logged-out page views are identical for everyone, so they can be cached at the edge outright
  3. Redis page cache β€” on CDN miss, the assembled page (question + ranked answers) is looked up by question ID
  4. Read replicas absorb misses β€” a cache miss reads from a replica, never the primary, so read traffic can never starve writes

How it works end-to-end (write path β€” 1% of traffic):

  1. Writes go to the Postgres primary β€” questions, answers, and votes, with a unique constraint on votes preventing double-counting
  2. CDC stream publishes changes to Kafka β€” one change feed, two consumers, so neither indexing nor ranking sits in the user’s request
  3. Elasticsearch stays in sync β€” the indexer consumes the stream and updates the search index within seconds
  4. Ranking Worker recomputes answer order β€” time-decayed scores are computed off the request path and written back to Redis as a pre-sorted list, so page loads never sort 500 answers

Key Technologies

Term What it is
Read Replica A streaming copy of the primary DB that serves reads only. The lever for a 100:1 read skew β€” add replicas, not shards.
Single-flight (request coalescing) On a cache miss, only one request fetches from the DB while the rest wait for its result. Prevents a thundering herd on viral content.
CDC (Change Data Capture) Streams a database’s commit log as events. Keeps Elasticsearch in sync without dual-writes, which would risk divergence.
BM25 Elasticsearch’s default relevance scoring β€” term frequency weighted by how rare the term is across the corpus.
Wilson score / time decay Ranking formulas that account for vote confidence and recency, so a new good answer can outrank an old popular one.

What’s Expected at Each Level

Level Expectations
Mid Redis cache for read-heavy pages. Elasticsearch for full-text search. Basic vote-sorted answers. Explain read/write ratio implications.
Senior Thundering herd protection (single-flight). CDC pipeline to sync ES. Time-decayed ranking formula. Read replicas for the long tail.
Staff+ Cache warming for trending content. Composite ranking with engagement signals. Faceted search with boost tuning. Feed generation strategy for followed topics.

🎯 Key Takeaways



Understand the building blocks used in this design:

Discussion

Newest first
You

Free system design + DSA prep. If it helped you crack an interview, consider supporting.