Limited time: AI code review, hints, mock interviews, whiteboard analysis, and all Pro features are unlocked. Enroll
⏱️ 17 min read

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 / System Interface

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

Technology Choices

Tier Purpose Primary Pick Alternatives Why Primary Wins Here
Primary DB Questions, answers, votes, users Postgres MySQL, CockroachDB, Aurora ACID for vote counting; fits in one instance with read replicas at this scale
Cache Hot question pages Redis Memcached, Varnish, Caffeine 100:1 read ratio makes cache load-bearing; TTL invalidation on vote/answer
Search Full-text question search + tag filtering Elasticsearch Algolia, Meilisearch, Typesense BM25 relevance ranking over 10M questions with tag facets
CDN Static assets + cached HTML for SEO CloudFront Cloudflare, Fastly, Akamai Most traffic is search-engine bots hitting question pages β€” edge cache dominates
Async Processing Ranking recomputation, search index sync Kafka RabbitMQ, SQS, Redis Streams Decouples write path from ranking updates; replay for reindexing

Why Postgres instead of sharding? 10M questions Γ— 50M answers Γ— ~2KB β‰ˆ 100GB of text. That fits comfortably in one Postgres instance with read replicas. Stack Overflow runs on a similar footprint. Sharding adds complexity that this read-heavy, moderate-write system doesn’t need.


Data Modeling

Postgres (Primary DB β€” questions, answers, votes):

CREATE TABLE questions (
    question_id UUID PRIMARY KEY,
    author_id UUID NOT NULL,
    title VARCHAR(300) NOT NULL,
    body TEXT NOT NULL,
    tags VARCHAR(50)[] NOT NULL,
    view_count INTEGER DEFAULT 0,
    vote_score INTEGER DEFAULT 0,
    answer_count INTEGER DEFAULT 0,
    accepted_answer_id UUID,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_questions_tags ON questions USING GIN(tags);
CREATE INDEX idx_questions_score ON questions(vote_score DESC);

CREATE TABLE answers (
    answer_id UUID PRIMARY KEY,
    question_id UUID NOT NULL REFERENCES questions(question_id),
    author_id UUID NOT NULL,
    body TEXT NOT NULL,
    vote_score INTEGER DEFAULT 0,
    is_accepted BOOLEAN DEFAULT false,
    created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_answers_question ON answers(question_id, vote_score DESC);

CREATE TABLE votes (
    user_id UUID NOT NULL,
    target_id UUID NOT NULL,  -- question_id or answer_id
    target_type VARCHAR(10) NOT NULL,  -- QUESTION or ANSWER
    direction SMALLINT NOT NULL,  -- +1 or -1
    created_at TIMESTAMP NOT NULL,
    PRIMARY KEY (user_id, target_id)
);

Redis (Cache β€” hot question pages):

Key: "question:{questionId}" β†’ Hash { title, body, vote_score, answer_count, accepted_answer_id }
Key: "answers:{questionId}:top" β†’ List of top-voted answer JSON objects
TTL: 5 minutes (invalidated on new answer or vote)

Elasticsearch (Search Index β€” full-text + tag filtering):

Index: questions
  Fields: title (text), body (text), tags (keyword[]), vote_score (integer), 
          answer_count (integer), created_at (date), author_name (keyword)
  Analyzer: english with stemming + synonyms

Access Patterns:

Query Data Source How
View question page Redis β†’ Postgres Cache hit β†’ return. Miss β†’ query Postgres, populate cache
Search questions Elasticsearch BM25 full-text over title+body, filtered by tags, sorted by relevance or votes
Upvote an answer Postgres INSERT INTO votes + UPDATE answers SET vote_score = vote_score + 1 (in txn) β†’ invalidate cache
Hot questions (homepage) Redis Pre-computed sorted list, refreshed every 5 minutes by a background job

How Vote Score Updates Propagate:

  1. User upvotes an answer β†’ INSERT INTO votes (user_id, target_id, 'ANSWER', +1) β€” unique constraint prevents double-voting
  2. UPDATE answers SET vote_score = vote_score + 1 WHERE answer_id = ? β€” atomic increment
  3. Publish VOTE_CAST event to Kafka β†’ cache invalidation worker DELetes question:{qId} and answers:{qId}:top from Redis
  4. Elasticsearch is updated async (Kafka consumer) β€” search results reflect new score within seconds
  5. Next page load rebuilds cache from Postgres (read-through pattern)

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.

SensAI SensAI
Beta
Listening...
Tap mic to stop voice mode

Shape what we build next

Every piece of feedback is read by the team and directly influences our roadmap.

What type of feedback?

Install SystemCraft

Add to your home screen for instant access, offline reading, and a distraction-free experience.

Offline reading Faster loads No browser tabs App-like feel

Unlock AI Features

One click to activate - no payment, no credit card. Just sign in and you're in.

AI code review and hints
SensAI chat assistant
AI mock interviews
Whiteboard analysis
100% free during early access