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:
- Hot questions (viral content) get 100K reads/sec β single DB canβt handle the load
- Full-text search with
LIKE '%keyword%'does a full table scan, no relevance ranking - Computing βtop answersβ on every read (sort by votes) is expensive when there are 500 answers
- Feed generation for 10M users requires scanning all followed topics per request
- No caching β same popular question page is recomputed on every request
The rest of the doc evolves this into a design that survives a front-page traffic spike.
Prior Art Weβre Drawing From
- Stack Overflow β famously runs on a remarkably small server footprint: a handful of SQL Server boxes plus aggressive caching, because an immutable-ish question page is close to a perfect cache candidate. Proof that read-heavy Q&A is a caching problem before it is a sharding problem. (Stack Overflow architecture)
- Quora β separates the write store from the read/serving path and computes ranking and feed material asynchronously, keeping page loads free of ranking work. (Quora Engineering)
- Reddit β its comment ranking uses time-decayed and confidence-based scoring (Wilson score for βbestβ) rather than raw vote counts, which is exactly the ranking problem in Deep Dive 2. (Reddit ranking algorithms)
Functional Requirements
Core (top 3)
- Post and read Q&A β users post questions, others write answers, anyone can read
- Vote on answers β upvote/downvote; answers ranked by score
- Search questions β full-text search across all questions by keyword and tags
Below the Line
- User profiles, comments on answers, tag system, personalized feed, notifications, moderation tools
Non-Functional Requirements
- Read latency β <100ms for question page loads (including top answers)
- Scale β 10M questions, 50M answers, 100:1 read-to-write ratio
- Search β full-text results in <200ms with relevance ranking
- Availability β 99.9%; read path must stay up even if write path degrades
Below the Line
- Strong consistency on vote counts (a vote appearing seconds late is fine)
- Real-time updates to an open page
- Multi-region active-active writes
Scale Estimation (Back-of-Envelope)
- Corpus: 10M questions, 50M answers (~5 answers/question)
- Read QPS: ~50K question-page reads/sec average; a front-page item can add 100K/sec on one key
- Write QPS: ~500/sec combined (new questions, answers, votes) β 100:1 skew
- Storage: 50M answers Γ ~2KB β 100GB of text, plus indexes. Fits comfortably in one Postgres instance with read replicas β this system does not need sharding, which is the main thing to say out loud.
- Search index: 10M docs Γ ~1KB indexed fields β 10GB, a small Elasticsearch cluster
- Cache working set: the hot 1% of questions β 100K pages Γ 50KB β 5GB in Redis
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
- Question β title, body, tags, author, created_at, view_count
- Answer β body, author, question reference, vote_score, created_at
- Vote β user, target (answer/question), direction (up/down)
- Tag β name, question count (for discovery)
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):
- User requests a question page β usually arriving from a search engine, so the request is cold for that user but hot for the system
- CDN serves anonymous traffic β logged-out page views are identical for everyone, so they can be cached at the edge outright
- Redis page cache β on CDN miss, the assembled page (question + ranked answers) is looked up by question ID
- 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):
- Writes go to the Postgres primary β questions, answers, and votes, with a unique constraint on votes preventing double-counting
- CDC stream publishes changes to Kafka β one change feed, two consumers, so neither indexing nor ranking sits in the userβs request
- Elasticsearch stays in sync β the indexer consumes the stream and updates the search index within seconds
- 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
- A 100:1 read skew makes the cache load-bearing, not an optimisation β design the read path first
- Donβt shard this system. 100GB and 500 writes/sec is one Postgres primary plus read replicas; proposing shards is the classic over-engineering trap here
- Rank asynchronously, serve pre-sorted. Sorting 500 answers per page load is the mistake; compute scores on a worker and cache the ordering
- Raw vote counts are a bad ranking. Time decay lets fresh, better answers surface past entrenched old ones
- Search is a separate failure domain β Elasticsearch going down should degrade search, not take down reading
- Invalidate on answer changes, expire votes by TTL β invalidating on every vote destroys the cache exactly when you need it most
Related Designs
- Search Autocomplete - prefix search for the search-as-you-type path
- Twitter Feed - fan-out and feed generation for followed topics
- News Aggregator - time-decayed ranking of user-submitted content
- Rate Limiter - protecting the write path from spam and vote abuse
Related Concepts
Understand the building blocks used in this design:
- Caching β β the load-bearing component for a 100:1 read-to-write ratio
- Database Replication β β read replicas absorb cache misses without touching the primary
- Database Indexing β β makes question and answer lookups fast in Postgres
- CDN β β serves identical anonymous question pages from the edge
- Batch vs Stream β β why ranking is recomputed off the request path
- Fan-Out β β the pattern behind feed generation for followed topics
Discussion
Newest first