List the key functional requirements for the system (Ask the AI for hints if stuck)...
Nested comment creation: Users post top-level comments on any piece of content (article, video, post) and reply to existing comments, creating a threaded hierarchy. Each reply is linked to its parent, forming a tree that can be multiple levels deep.
Edit and delete own comments: Users can edit their own comments after posting and delete them. Deletion of a comment that has replies shows a "[deleted]" placeholder rather than removing the entire subtree, preserving the context for child replies.
Real-time reply notifications: When someone replies to your comment, you receive a notification within seconds. The notification system is decoupled from the comment write path so that a notification service outage never blocks comment creation.
Upvote and downvote: Users can upvote or downvote any comment. Each user gets one vote per comment (toggling or changing is allowed). Vote scores influence sorting order.
Sorting: Comments can be sorted by newest, oldest, or highest vote score. The default sort is by vote score at the top level and chronological within reply threads.
Depth limit: Nesting is capped at 5 levels. Replies to a depth-5 comment are posted at depth 5 (same level as the parent), preventing deeply nested threads that are hard to read and expensive to query.
List the key non-functional requirements (performance, scalability, reliability, etc.)...
Low latency: Fetching a page of comments with their nested replies returns in under 200ms. Posting a new comment feels instant (under 300ms from the user's perspective).
Scalability: The system handles millions of comments per day across millions of discussions. Hot discussions (viral posts with thousands of comments) do not degrade performance for other discussions.
High availability: Target 99.9% uptime. Comment reading remains available even if the notification service or cache layer is down.
Data consistency: No comments are lost, no duplicate comments appear on retry, and vote counts remain accurate under concurrent voting.
Estimate the scale of the system. Consider daily active users, read/write ratio, storage requirements, bandwidth, and any relevant QPS calculations...
50 million comments per day: A Reddit-scale platform. Average throughput: 50M / 86,400 = roughly 580 writes per second. This is low. A single PostgreSQL instance handles this easily.
Peak write throughput: 5,000 comments/sec: When a major news event breaks or a celebrity posts, comment activity spikes 8-10x. The system must absorb these bursts without dropping comments.
Read-to-write ratio: 100:1: For every comment written, 100 users read it. That means 58,000 read requests per second at average, and 500,000 reads/sec at peak. This is the number that drives the architecture: the read path must be heavily cached and optimized.
Define the APIs expected from the system. This is your chance to analyze and define the read and write paths so that you can come up with the high-level design...
POST /v1/discussions/:discussion_id/comments
Headers: Authorization: Bearer <token>, X-Idempotency-Key: <uuid>
Body: {
content: string,
parent_id: string | null
}
Response: 201 Created {
comment_id: string,
content: string,
author: { user_id, username },
parent_id: string | null,
depth: number,
created_at: ISO-8601
}
The parent_id is null for top-level comments and set to an existing comment ID for replies. The server validates that the parent exists and belongs to the same discussion. If the parent is at depth 5, the reply is created at depth 5 (same level) rather than rejected.
The X-Idempotency-Key header prevents duplicate comments on retry. If the client's POST times out and it retries with the same key, the server returns the already-created comment rather than creating a duplicate.
GET /v1/discussions/:discussion_id/comments?sort=score&cursor=<last_id>&limit=20
Response: 200 OK {
comments: [
{
comment_id, content, author, depth, vote_score,
reply_count, created_at,
replies: [{ ... nested up to depth 5 ... }]
}
],
next_cursor: string | null
}
The response returns top-level comments with their full reply trees nested inline (up to depth 5). Pagination uses cursor-based navigation on top-level comments only. This means each page is self-contained: you never see a reply without its parent on the same page.
POST /v1/comments/:comment_id/vote
Body: { value: 1 | -1 }
Response: 200 OK { vote_score: number }
Idempotent by user: voting the same direction twice is a no-op, and switching direction updates the score by 2 (removes the old vote and applies the new one).
PATCH /v1/comments/:comment_id
Body: { content: string }
Response: 200 OK { comment_id, content, updated_at }
DELETE /v1/comments/:comment_id
Response: 200 OK { status: "soft_deleted" }
Only the comment author can edit or delete. The server enforces this with an ownership check against the authenticated user. Delete performs a soft delete: content is replaced with "[deleted]" and the author field is cleared.
Describe the overall system architecture. Identify the main components needed to solve the problem end-to-end. Use the diagramming tool to create a block diagram.
The architecture follows a standard read-heavy web service pattern: an API gateway routes requests to a Comment Service, which reads from a Redis cache (fast path) or PostgreSQL (slow path). A message queue decouples comment creation from notification delivery, ensuring the write path is never blocked by downstream services.
High-level architecture: API Gateway routes to Comment Service. Comment Service reads/writes PostgreSQL, caches in Redis, and publishes events to Message Queue for Notification Service.
Level Expectations
Mid-level: Draw the core components (API Gateway, Comment Service, PostgreSQL, Redis) and explain the read and write paths separately.
Senior: Discuss why the Notification Service is decoupled via a message queue rather than called synchronously, and explain cache invalidation strategy for comment trees.
Staff: Analyze how the system handles a viral discussion with 10,000 comments/minute. Discuss cache stampede prevention (single-flight pattern), read replica lag implications for tree consistency, and the trade-off between pre-computing full trees in cache vs. assembling on demand.
Client to API Gateway: The client sends a POST request to create a comment or reply. The API gateway authenticates the user via JWT token and routes to the Comment Service.
Comment Service writes to PostgreSQL: The service validates the request (parent exists, depth limit not exceeded, content length within bounds), computes the materialized path by appending the new comment's ID to the parent's path, and inserts the comment in a transaction that also increments the parent's reply_count.
Cache invalidation: After a successful write, the Comment Service invalidates the cached comment tree for that discussion. The next read request triggers a cache miss that repopulates from the database.
Event publishing: The Comment Service publishes a "comment.created" event to the message queue (RabbitMQ). This event contains the comment ID, discussion ID, and parent comment author ID. The Notification Service consumes this event asynchronously.
Cache hit (fast path): The Comment Service checks Redis for the cached comment tree. If present, it returns the serialized JSON directly. This serves the majority of reads for hot discussions in under 5ms.
Cache miss (slow path): On a miss, the service queries PostgreSQL for all comments in the discussion (filtered by materialized path prefix for the requested page), assembles the tree structure in application code, caches the result in Redis with a 5-minute TTL, and returns it to the client.
Message Queue to Notification Service: The Notification Service consumes "comment.created" events and identifies the parent comment's author. It publishes a notification to Redis Pub/Sub on a channel specific to that user. If the user has an active WebSocket or SSE connection, they receive the notification in real time. If not, the notification is stored for later retrieval.
This three-path architecture (write, read, notify) ensures that each concern scales independently. The write path is bounded by database insert throughput. The read path is bounded by cache capacity. The notification path is bounded by message queue throughput. No single bottleneck affects all three.
Two request flows define the system: posting a reply (the write flow) and loading a comment thread (the read flow). Walking through each step reveals where failures can occur and how the architecture handles them.
Reply creation: client sends POST, Comment Service validates parent, inserts with materialized path, invalidates cache, publishes event for notification.
Step 1: Client sends POST request: The user types a reply and clicks submit. The client sends POST /v1/discussions/:id/comments with content and parent_id, plus an X-Idempotency-Key header generated client-side.
Step 2: API Gateway authenticates: The gateway validates the JWT token and extracts the user_id. Invalid tokens get 401 Unauthorized.
Step 3: Comment Service validates: The service checks that the parent comment exists and belongs to the same discussion. It reads the parent's depth: if depth is less than 5, the new comment gets depth + 1. If depth equals 5, the new comment gets depth 5 (capped). It also checks the idempotency key against a Redis set with 24-hour TTL.
Step 4: Database transaction: In a single transaction, the service inserts the new comment row (with computed path and depth) and increments the parent's reply_count. Both operations succeed or both roll back.
Step 5: Cache invalidation: The service deletes the Redis cache entry for this discussion's comment tree. The next read will rebuild from the database.
Step 6: Event publishing: The service publishes a "comment.created" event to RabbitMQ with the comment ID, discussion ID, and parent author ID.
Step 7: Response: The service returns 201 Created with the new comment data. The client immediately renders it in the thread.
Step 8: Notification delivery (async): The Notification Service consumes the event, looks up the parent author, and publishes to Redis Pub/Sub. The parent author receives a real-time notification if connected.
Comment thread loading: check Redis cache first, on miss query PostgreSQL with materialized path prefix, assemble tree, cache result, return nested JSON.
Step 1: Client requests comments: The user opens a discussion page. The client sends GET /v1/discussions/:id/comments?sort=score&limit=20.
Step 2: Cache check: The Comment Service checks Redis for the cached comment tree for this discussion and sort order.
Step 3a: Cache hit: If the cache entry exists, the service returns the serialized JSON directly. Response time is under 5ms.
Step 3b: Cache miss: If not cached, the service queries PostgreSQL. It fetches the top 20 top-level comments (sorted by vote_score or created_at) and all their descendants (WHERE path LIKE '/top_comment_path/%'). The query uses the (discussion_id, path) index.
Step 4: Tree assembly: The service assembles the flat query results into a nested tree structure using the path column to determine parent-child relationships. This runs in O(n) time where n is the total number of comments returned.
Step 5: Cache population: The assembled tree is serialized to JSON and stored in Redis with a 5-minute TTL.
Step 6: Response: The nested JSON is returned to the client with pagination cursor for the next page.
Define the data model. Identify the main entities, their attributes, and relationships. Consider the choice of database type (SQL vs NoSQL) and justify your decision based on access patterns...
The database design is where the nested comments problem gets interesting. Flat data (users, votes) is straightforward. The core decision is how to represent a tree of comments in a relational database so that both writes (inserting a new reply) and reads (fetching a subtree) are efficient.
comments
comment_id UUID PK
discussion_id UUID FK -> discussions
parent_id UUID FK -> comments (nullable)
author_id UUID FK -> users
content TEXT
path VARCHAR(900) e.g. "/a1b2/c3d4/e5f6"
depth SMALLINT 0-5
reply_count INT denormalized
vote_score INT denormalized
is_deleted BOOLEAN soft delete flag
created_at TIMESTAMP
updated_at TIMESTAMP
Indexes:
(discussion_id, path) - subtree queries
(discussion_id, vote_score DESC, created_at) - sorted listing
(parent_id) - direct children lookup
The path column stores the full ancestry chain. A top-level comment has path "/a1b2". Its reply has path "/a1b2/c3d4". A reply to that reply has path "/a1b2/c3d4/e5f6". Fetching all descendants of comment a1b2 is a single indexed query: WHERE path LIKE '/a1b2/%'.
Why materialized path over adjacency list? An adjacency list (just parent_id) requires a recursive CTE to fetch a subtree. PostgreSQL supports recursive CTEs, but they execute one join per depth level. With 5 levels of nesting and 200 comments per discussion, a recursive CTE runs 5 iterations. The materialized path query runs once with an index scan. At 58,000 reads/sec, this difference matters.
votes
vote_id UUID PK
user_id UUID FK -> users
comment_id UUID FK -> comments
value SMALLINT 1 or -1
created_at TIMESTAMP
Unique constraint: (user_id, comment_id)
Index: (comment_id) - for aggregation
The unique constraint on (user_id, comment_id) prevents double voting at the database level. Vote score is denormalized on the comment row (vote_score column) and updated atomically with each vote: UPDATE comments SET vote_score = vote_score + :delta WHERE comment_id = :id.
Deep dive into 2-3 key components. Explain how they work, how they scale, discuss tradeoffs, capacity, and any relevant algorithms or data structures.
The core engineering challenge (the GATE of this problem) is retrieving nested comments efficiently without scanning the entire comment tree. Every design decision in this section serves that goal: fast subtree retrieval for deeply nested threads under high read concurrency.
Key Insight
The GATE insight: a naive approach fetches all comments and builds the tree in application code. This works for 100 comments but fails at 10,000. The materialized path with prefix indexing converts tree traversal into a string prefix scan, which PostgreSQL handles with a standard B-tree index. One query, one index scan, all descendants returned in order.
Tree retrieval strategies compared: adjacency list with recursive CTE vs. materialized path with prefix LIKE query vs. closure table with join. Materialized path gives single-query subtree retrieval.
The materialized path approach stores the complete ancestry of each comment as a string. When a user fetches comments, the query is:
SELECT * FROM comments WHERE discussion_id = :id AND path LIKE ':root_path/%' ORDER BY path
This returns all descendants of a given comment in a single query. The ORDER BY path clause returns comments in depth-first order (parent before children), which maps directly to the indented display. The (discussion_id, path) composite index makes this a fast range scan.
For the top-level page, the query first fetches the top 20 root comments (WHERE parent_id IS NULL, sorted by vote_score), then fetches all their descendants with 20 LIKE queries (one per root comment). In practice, these are batched into a single query using OR conditions or UNION ALL.
Why not adjacency list with recursive CTE? PostgreSQL's WITH RECURSIVE executes one iteration per depth level. For a depth-5 tree, that is 5 iterations. Each iteration joins the comments table with the intermediate result set. For a discussion with 5,000 comments, each iteration scans the parent_id index for all comments found in the previous level. The total work grows with both depth and breadth. The materialized path query does constant work regardless of depth: one index scan per root comment.
Handling path updates: Comments are never moved between parents in a comment system (unlike a file system). This makes materialized paths ideal: the path is written once at insert time and never updated. The main write-path weakness of materialized paths (expensive subtree moves) never applies.
The depth limit of 5 bounds query complexity, but breadth (the number of replies at each level) can still be large. A single top-level comment might have 500 direct replies. The system handles this with lazy-loading:
The initial response includes the first 5 replies per parent comment and a reply_count showing how many total exist. The client renders a "Load more replies (495 remaining)" link. Clicking it fetches the next batch from the server using the parent comment's path as a cursor.
This approach keeps the initial page load fast (at most 20 x 5 x 5 = 500 comments per page in the worst case) while allowing users to explore deeper threads on demand.
Notification delivery: Comment Service publishes to RabbitMQ. Notification Service consumes, looks up parent author, publishes to Redis Pub/Sub. WebSocket server pushes to connected client.
When a reply is posted, the Comment Service publishes a "comment.created" event to RabbitMQ. The Notification Service consumes this event, looks up the parent comment to find the author, and publishes a notification message to Redis Pub/Sub on a user-specific channel (notifications:user-{author_id}).
All WebSocket server instances subscribe to Redis Pub/Sub. The instance holding the target user's connection receives the message and pushes it to the user's browser. If the user is offline, the notification is written to a notifications table in PostgreSQL for retrieval on next login.
Why Redis Pub/Sub for cross-instance routing? With multiple WebSocket server instances behind a load balancer, the instance that processed the comment creation is likely different from the instance holding the notification recipient's WebSocket connection. Redis Pub/Sub acts as a broadcast layer: publish once, and every subscribed instance receives the message. Only the instance with the user's connection acts on it.
Network timeouts and client retries can cause duplicate comment submissions. The system prevents this with a client-generated idempotency key (a UUID sent in the X-Idempotency-Key header). The Comment Service checks this key against a Redis set before processing:
This handles the common failure mode where a mobile client retries after a timeout but the original request actually succeeded.
When a hot discussion's cache entry expires or is invalidated, hundreds of concurrent readers hit the database simultaneously (thundering herd). The system uses a single-flight pattern: the first request to miss the cache acquires a short-lived lock in Redis. Subsequent requests for the same discussion wait for the lock rather than querying the database independently. When the first request populates the cache, the waiting requests serve from the newly cached data.
This converts N simultaneous database queries into 1 query plus N-1 cache reads, protecting the database during cache transitions on hot discussions.
Every major decision in this system involves choosing between competing concerns. Understanding these trade-offs and being able to articulate them in an interview demonstrates that you made deliberate choices rather than defaulting to familiar patterns.
Adjacency list (parent_id column only) is the simplest approach. Writes are a single insert. Reads require recursive CTEs to traverse the tree. Best for shallow trees with infrequent reads.
Materialized path (path string column) adds a string per comment but enables single-query subtree retrieval via LIKE prefix scan. Writes are slightly more expensive (compute path from parent). Best for append-only trees with heavy reads, which is exactly what a comment system is.
Closure table (separate table of ancestor-descendant pairs) enables fast subtree queries and flexible ancestor lookups. But each comment insert requires inserting one row per ancestor (up to 5 rows for depth 5). Storage grows quadratically with tree depth. Best when you need both descendant and ancestor queries frequently.
Our choice: materialized path because comment trees are append-only (no moves), reads dominate writes 100:1, and the depth cap of 5 keeps path strings short.
WebSocket enables bidirectional communication but requires persistent connections, a connection management layer, and sticky load balancing or a pub/sub broadcast layer. Operational complexity is high.
Server-Sent Events (SSE) is simpler: unidirectional server-to-client push over HTTP. No special load balancer configuration needed. But it only works for server-to-client messages.
Long polling is the simplest: the client repeatedly asks "any new notifications?" The server holds the request until there is data or a timeout. No persistent connections, but higher latency and more server load from repeated connections.
Our choice: SSE for notifications because the flow is unidirectional (server pushes to client), it works over standard HTTP infrastructure, and the operational overhead is far lower than WebSocket for this use case.
Denormalized counts (vote_score and reply_count on the comment row) make reads fast but require updating the comment row on every vote or reply. This is a write amplification cost.
Computed counts (COUNT queries at read time) keep writes simple but add expensive aggregation queries to every read at 58,000 reads/sec.
Interview Tip
In an interview, always state your choice and the reasoning together. Do not just say 'I would use materialized path.' Say 'I would use materialized path because comments are append-only, reads outnumber writes 100:1, and the depth cap of 5 keeps path strings short enough for efficient indexing.' The reasoning is what distinguishes a senior answer from a junior one.
Our choice: denormalized counts because the read-to-write ratio is 100:1. Paying the write cost once to avoid the read cost 100 times is a net win. Atomic SQL updates (SET vote_score = vote_score + 1) keep the denormalized values accurate under concurrency.