Interview Setup
Interview Prompt
Design a threaded discussion forum like Reddit with subreddits, nested comment trees, voting, and ranked feeds.
Clarifying Questions (ask before designing)
| Question | Why it matters |
|---|---|
| How deep can comment threads go, and do we load the full tree or lazy-load branches? | A viral post with 10K+ comments cannot return in a single query. Materialized path indexing combined with cursor pagination provides predictable retrieval, whereas recursive adjacency-list common table expressions break down under deep trees. |
| Which ranking modes matter most between hot, best, and controversial, or do we support all three using distinct mathematical formulas? | Hot ranking applies a time-decayed logarithmic score to posts, whereas the best sort uses Wilson score confidence intervals for comments. New ranking is strictly chronological by creation timestamp. Each mode requires a dedicated Redis sorted set key and an asynchronous background recomputation path. |
| How stale can vote counts and karma be before users notice? | A throughput of 500M votes per day (averaging ~6,000 per second with 30,000 peak) cannot hit PostgreSQL synchronously. While eventual consistency on displayed counters is acceptable, duplicate voting by the same user must be strictly prevented via atomic Redis CAS transitions backed by durable database unique constraints. |
| What scale are we designing for in terms of daily active users, posts, comments, and vote volume? | Anchors the math: 50M DAU, 5M posts/day, 50M comments/day, 500M votes/day (555M total writes/day). At a 100:1 read-to-write ratio, this generates 55.5B reads/day (~642K QPS avg, ~1.5M peak). Storage grows at 35 GB/day raw content before indexing overhead. |
Scope
In scope
- Nested comment trees (materialized path)
- Hot, best, and controversial ranking
- Subreddit isolation and ranked feeds
- Async vote aggregation (Redis + Kafka)
- Capacity estimation with shown math
Out of scope (state explicitly)
- Full ML ranking model training pipeline
- Direct messaging and real-time chat infrastructure
- Ad insertion and monetization
Functional Requirements
Core discussion capabilities include community creation, rich content publishing, deeply nested comment conversations, voting, and personalized feed generation:
- Subreddits: Create, discover, and join topic-based interest communities.
- Posts: Submit text posts, links, images, and videos with community-specific flair tags.
- Comments: Engage in deeply nested, threaded discussions represented as a conversation tree.
- Voting: Cast upvotes and downvotes on posts and comments to signal relevance and quality.
- Ranking: View community feeds sorted by Hot, New, Top, Rising, and Controversial.
- User Karma: Accumulate an aggregate reputation score derived from upvotes and downvotes received.
- Home Feed: Consume an aggregated, personalized feed assembling top posts from subscribed subreddits.
- Search: Discover relevant posts, comments, and subreddits through full-text search.
- Moderation: Subreddit moderators manage content by removing policy-violating items and banning repeat offenders.
- Awards and Gilding: Reward standout posts and comments with premium community awards.
Non-Functional Requirements
Comment tree reads and high-volume vote aggregation present significantly tougher scalability challenges than post creation. Your interviewer will ask which ranking mode (hot, new, or top) you optimize for first, because each implies different precomputation intervals and cache key structures.
- High Availability: Maintain 99.99% uptime for core browsing and voting interactions.
- Low Latency: Front page loads in under 200 ms p99, while comment threads load in under 500 ms p99.
- Scalability: Support 50M+ daily active users and millions of new posts and comments daily.
- Read-Heavy Workload: Accommodate a 100:1 read-to-write traffic ratio across feeds and discussion threads (555M writes/day, 55.5B reads/day, ~642,360 avg read QPS, ~1.5M peak QPS).
- Eventual Consistency: Allow displayed vote totals and karma scores to remain slightly stale while strictly preventing duplicate voting via atomic Redis CAS transitions and authoritative database unique keys.
- Deep Comment Trees: Gracefully navigate viral threads containing over 10,000 comments nested more than 20 levels deep.
Capacity Estimations
Evaluating write volume, read-to-write distribution, and vote velocity indicates whether ranking scores should be computed synchronously on read or precalculated in background queues:
| Metric | Calculation | Value |
|---|---|---|
| DAU | Given (product assumption) | 50M |
| Posts / day | 50M DAU x 0.1 | 5M |
| Comments / day | 50M DAU x 1 | 50M |
| Votes / day | 50M DAU x 10 | 500M |
| Total write volume | 5M posts + 50M comments + 500M votes | 555M/day (~6,424/sec avg, 35K peak) |
| Read-to-write ratio | Given (read-heavy browsing workload) | 100:1 |
| Total read volume | 555M writes/day x 100 | 55.5B/day (~642,360 QPS avg, ~1.5M peak) |
| Read QPS breakdown | Feed reads ~40% (~257K QPS), threads ~50% (~321K QPS), other ~10% (~64K QPS) | ~642K avg QPS |
| Avg post size | Given (typical workload assumption) | 2 KB |
| Avg comment size | Given (typical workload assumption) | 500 bytes |
| Content storage / day | (5M x 2KB) + (50M x 500B) | 35 GB |
| Hot post cache | 100K posts x 10 KB | 1 GB |
| Active home-feed merges | Bounded to active window: ~2.5M concurrent users ÷ 300s TTL (lazy rebuild for inactive) | ~8,300 merges/sec |
Read Workload & Feed Merge Sizing: At 50M DAU, total write volume across posts (5M), comments (50M), and votes (500M) reaches 555M writes/day (~6,424 writes/sec average, 35,000 peak). With a 100:1 read-to-write browsing ratio, total read traffic reaches 55.5 Billion reads/day (~642,360 average read QPS, peaking at ~1.5M QPS). Feed reads account for ~40% (~257K QPS), comment thread loads account for ~50% (~321K QPS), and search/user reputation lookups represent ~10% (~64K QPS). Over 95% of feed and score reads hit Redis and CDN edge tiers directly. Rebuilding home feeds for all 50M users every 5 minutes would require an impossible 166,667 merges/sec. Instead, proactive merges are strictly bounded to the active user window (~2.5M concurrent users in a 15-minute window), requiring only ~8,300 merges/sec across horizontally sharded feed workers, while inactive users are rebuilt lazily on demand.
Architecture Diagram
In an interview, clarify hot, new, and top ranking upfront because each sort mode requires its own precomputed Redis sorted set. New ranking is strictly chronological by creation timestamp and does not require worker recomputation.
The architecture navigates three critical paths: post creation (validating metadata, storing media in S3, persisting to PostgreSQL sharded by subreddit_id, and emitting post-events to Kafka), vote ingestion (atomic Redis Lua CAS state transition, publishing to Kafka with acks=all, and asynchronous batch persistence to PostgreSQL user_votes), and feed serving (reading precomputed hot scores from Redis sorted sets per subreddit, bounded active home-feed merges, and lazy-loading nested comment trees on demand).
Comments form a hierarchical tree modeled with materialized paths sharded by post_id, avoiding the trap of loading deep 10,000-comment threads in a single query. Hot ranking evaluates time-decayed logarithmic scores updated on vote events rather than on every user read. Meanwhile, the home feed aggregates content from subscribed subreddits by fetching the top 25 hot posts from each community and executing a bounded k-way merge ordered by normalized score.
Component Deep Dives
Threaded comments and voting represent the core architectural challenges of the system. We explore ranking algorithms and cache key designs first, followed by comment tree storage and asynchronous vote aggregation.
Post Service
Handles post authoring, validation, media ingestion, and event broadcasting:
- Create post: Validates content length and mandatory subreddit flair, uploads media attachments to S3, persists the record to PostgreSQL (sharded by
subreddit_id), and publishes apost-eventsmessage to Kafka. - Post types: Text, link, image, video, and poll submissions, each stored with type-specific metadata schemas.
- Flair: Enforces per-subreddit tag taxonomies such as Discussion, Question, or News.
- Cross-posting: Creates an independent post record in the target subreddit referencing the original through
crosspost_parent_id. Vote scores, comments, and community moderation remain fully isolated. - Awards & gilding: Handles community awards via
giveAward, validating user coin balance, recording the transaction in theawardstable, incrementingposts.award_count, updating Redis awards hash caches, and publishing anaward-eventsmessage to Kafka. - Soft delete & removal: Preserves tombstone records to maintain comment tree referential integrity, replacing the author name and content body with "[deleted]". Emits a
post-eventsremoval message that triggers eviction from Redis sorted sets (ZREM), invalidation of cached home feeds, purging of CDN edge cache tags, and de-indexing from Elasticsearch.
Subreddit Service
Manages community lifecycles, subscriber memberships, access policies, and moderation tooling:
- Community lifecycle: Oversees subreddit creation, customizable community rules, moderator assignments, and user ban enforcement.
- Membership tracking: Maintains the
user_subredditsrelationship table, which the Feed Service queries to assemble personalized home feeds. - Access control: Supports public browsing, restricted posting where only approved contributors publish, and private invite-only subreddits.
- Moderator actions: Provides interfaces to remove policy-violating posts and comments, ban bad actors, pin announcements, and configure AutoModerator regex filters.
Comment Service
Orchestrates comment lifecycle operations, tree indexing, and branch retrieval:
- Materialized path storage: Encodes lineage hierarchy using dotted string paths formatted with fixed-width 6-character zero-padded base-36 segments such as
000001.000003.000007where depth is directly derived from string segment count. - Concurrent sibling allocation: Allocates the next monotonic sibling segment under the parent using an atomic per-parent sequence (such as Redis counter
seq:comment:{parent_id}or PostgreSQL atomic counterUPDATE comments SET child_seq = child_seq + 1 WHERE comment_id = ? RETURNING child_seq). For root comments, it allocates fromseq:post:{post_id}. Sibling segments are formatted as fixed-width 6-character zero-padded base-36 identifiers (such as000001,000002,00000Z), which support over 2.17B ($36^6$) siblings per parent while preserving strict ASCII lexicographical sort order. Appending this segment to the parent's path (parent.path + '.' + allocated_segment) ensures concurrent replies to the same parent receive distinct non-colliding paths without race conditions. - Sorting modes & ranking consistency: Supports best sorting using materialized
wilson_score, top sorting by net score, new sorting by timestamp, and controversial sorting using materializedcontroversial_score. Top-level comments (depth = 0) are queried usingidx_post_wilson(ORDER BY wilson_score DESC) oridx_post_controversial(ORDER BY controversial_score DESC), while child branches are fetched by path prefix (WHERE post_id = ? AND path LIKE '000001.000003.%'). Within each branch, child comments are ordered bypathfor natural pre-order conversation tree rendering in the UI. A single SQL query withORDER BY path, scoredoes not produce a globally Wilson-sorted tree because child nodes must remain structurally grouped under their respective parents. - Lazy loading: Returns top-level comments first and fetches nested reply subtrees on demand using cursor pagination on the path prefix.
- Awards & gilding: Bestows awards on comment nodes via
giveAward, persisting to theawardstable, incrementingcomments.award_count, and dispatching anaward-eventsmessage to Kafka. - Soft delete: When a comment is removed, if it has child replies, its content is masked to "[deleted]" and
is_removed = trueto preserve path continuity for descendant replies, whereas a leaf node with no replies can simply be pruned.
Comment Tree Storage: Architectural Approaches
Modeling deeply nested comment trees requires balancing write latency against complex hierarchical reads:
Approach 1: Adjacency List (Simple Writes, Slow Deep Traversal)
-- Schema: comments (comment_id, post_id, parent_comment_id, ...)
-- Retrieving the full tree requires recursive CTEs or iterative round tripsApproach 2: Materialized Path (Recommended)
-- Schema: comments (comment_id, post_id, path, child_seq, wilson_score, controversial_score, ...)
-- path = "000001.000003.000007.00000F" (ordered lineage from root in 6-character base-36 segments)
-- Retrieve subtree: WHERE post_id = ? AND path LIKE '000001.000003.%'
-- Sorting by path yields pre-order traversal for natural UI renderingApproach 3: Nested Set Model (Fast Reads, Heavy Write Amplification)
-- Schema: comments (comment_id, post_id, lft, rgt, ...)
-- Retrieve all descendants: WHERE lft > parent.lft AND rgt < parent.rgt
-- Inserting a comment requires locking and updating lft and rgt boundaries across the tableApproach 4: Closure Table (Flexible Queries, O(Depth) Insert Cost)
-- Schema: comment_tree (ancestor_id, descendant_id, depth)
-- Stores one row for every ancestor-descendant pairing
-- Enables fast traversal but incurs O(depth) record inserts for every replyRecommendation: Adopt the Materialized Path pattern for a Reddit-scale system. It delivers efficient single-query subtree reads, simple append writes, and natural hierarchical sorting without recursive query overhead.
Vote Service
Processes high-throughput vote traffic while preventing write contention on relational rows:
- Throughput challenge: Ingesting 500M votes per day generates intense write contention (~6,000 votes/sec average, 30,000 peak) when thousands of users interact with the same viral post simultaneously.
- Atomic Lua CAS transition: Concurrent requests from the same user cannot both pass the Redis check. The Vote Service executes an atomic Lua script against
user_votes:{user_id}:{entity_type}:{entity_id}:- Same direction: Returns an idempotent no-op without mutating state or re-emitting events.
- New vote (was 0 or nil): Atomically sets the new direction (
1or-1) with a 30-day sliding TTL and generates net delta+1or-1. - Changed vote (direction flip): Atomically replaces the old direction with the new direction and calculates net delta (
+1to-1is-2, while-1to+1is+2). - Removed vote (direction 0): Atomically deletes the key and calculates net delta (
+1to0is-1, while-1to0is+1).
- Durable source of truth & TTL fallback: Redis user-vote state acts as a fast serving and deduplication cache. PostgreSQL
user_votesis the durable source of truth. If a Redis vote-state key is missing or expired, the service queries PostgreSQL first before applying the transition to prevent duplicate votes across TTL boundaries. The composite primary key(user_id, entity_type, entity_id)in PostgreSQL serves as the immutable authoritative backstop. - Redis + Kafka failure semantics:
- The service applies the atomic CAS state change tentatively in Redis.
- It publishes an event payload to Kafka
vote-eventswithacks=all:{ event_id, user_id, entity_type, entity_id, old_direction, new_direction, delta, timestamp }. - Kafka producer timeouts are treated as ambiguous outcomes, where
event_id-based consumer idempotency makes replay or duplicate publication safe. The service reconciles Redis serving state with the durable event stream rather than assuming a timeout means the event was never committed. On definite pre-publish errors (such as local payload validation failure or client-side connection drops before dispatch), tentative Redis state is rolled back immediately and an HTTP 503 error is returned. - On publication acknowledgement, the service immediately returns HTTP 200 with
{ accepted: true, voteState: new_direction, eventId, approximateScore }. Authoritative score updates remain strictly asynchronous.
- Downstream consumer processing & delta idempotency:
vote-db-writer: Transactionally deduplicates incoming events byevent_idagainst the durable PostgreSQLprocessed_eventstable (enforced byPRIMARY KEY (consumer_group, event_id)) prior to upsertinguser_votesand executingUPDATE posts/comments SET score = score + delta. This ensures Kafka replays or consumer retries across the full 7-day retention window never apply duplicate score deltas.vote-counter: Enforces fast-path event idempotency in Redis using an atomic check-and-set with a 7-day TTL matching Kafka topic retention (SET processed_events:counter:{event_id} 1 EX 604800 NX), backed by the durable PostgreSQLprocessed_eventsledger on cache miss. If the event has already been recorded, counter application is skipped. Otherwise, it applies net delta increments to Redis score hashes atscore:{entity_type}:{entity_id}viaHINCRBY. The Redis counter cache is explicitly non-authoritative and continuously reconciled against PostgreSQL durable scores.
- Cache reconstruction: If Redis is lost, vote counts and user vote states are reconstructed from PostgreSQL
user_votesor replayed from compacted Kafka event logs.
Ranking Algorithms: Deep Dive
Reddit relies on distinct scoring algorithms tailored to content types and freshness requirements:
Hot Ranking (Time-Decayed Post Scoring)
import math
from datetime import datetime
def hot_score(ups, downs, created_at):
score = ups - downs
order = math.log10(max(abs(score), 1))
sign = 1 if score > 0 else -1 if score < 0 else 0
# Epoch: Dec 8, 2005 (Reddit's birthday)
epoch = datetime(2005, 12, 8, 7, 46, 43)
seconds = (created_at - epoch).total_seconds()
return round(sign * order + seconds / 45000, 7)- Time dominance: Time is the primary ranking factor. A post published 12.5 hours earlier requires 10 times more net upvotes to maintain parity with a brand-new post.
- Logarithmic scale: Moving from 10 to 100 net votes contributes the exact same score increment as moving from 100 to 1,000 net votes, preventing popular threads from permanently dominating the front page.
Wilson Score ("Best" Comment Ranking)
import math
def wilson_score(ups, downs, confidence=0.95):
n = ups + downs
if n == 0:
return 0
z = 1.96 # 95% confidence
p = ups / n
return (p + z*z/(2*n) - z * math.sqrt((p*(1-p) + z*z/(4*n)) / n)) / (1 + z*z/n)- Confidence interval lower bound: Solves the imbalance where a comment with a single upvote (100% positive) would otherwise rank above a proven comment with 100 upvotes and 1 downvote (99% positive).
- Sample size weighting: Calculates the lower bound of the 95% confidence interval for a Bernoulli parameter, stored in the materialized
wilson_scorecolumn so that comments with larger sample sizes rank higher when their positive ratio is solid.
Controversial Ranking
def controversial_score(ups, downs):
if ups <= 0 or downs <= 0:
return 0
magnitude = ups + downs
balance = min(ups, downs) / max(ups, downs)
return magnitude * balanceEvaluates total vote volume balanced against the ratio of minority to majority votes ((ups + downs) * (min(ups, downs) / max(ups, downs))), surfacing discussions with intense engagement and an even upvote/downvote split. For comments, this metric is materialized in the comments.controversial_score column and indexed with idx_post_controversial for efficient depth-0 subtree queries.
Rising Ranking (Velocity of Fresh Posts)
def rising_score(delta_ups_1h, delta_comments_1h, age_in_hours):
# Evaluates short-term engagement velocity for posts created in the last 6 to 12 hours
if age_in_hours > 12:
return 0.0
velocity = (delta_ups_1h * 2.0) + (delta_comments_1h * 3.0)
time_decay = (age_in_hours + 0.5) ** 1.5
return velocity / time_decayMeasures short-term engagement acceleration for posts submitted within the last 6 to 12 hours. Unlike Hot ranking (which applies logarithmic scaling across cumulative votes over a 12.5-hour half-life) or Top ranking (which tallies raw vote totals over fixed windows), Rising surfaces breakout discussions before they accumulate large aggregate totals. The Ranking Worker computes rising scores every 60 to 120 seconds and maintains the feed:sub:{subreddit_id}:rising sorted set in Redis.
New Ranking (Strict Chronological Ordering)
New ranking is strictly timestamp-based (using created_at epoch milliseconds or Snowflake ID timestamps). It is written once during post creation into feed:sub:{subreddit_id}:new and does not require continuous score recalculation by background ranking workers.
Ranking Worker
Asynchronously consumes event streams and recalculates ranking metrics without blocking the vote ingestion pipeline:
- Event stream consumption: Subscribes to Kafka
vote-eventsandpost-eventstopics across dedicated consumer groups. - Score recalculation: Periodically recomputes logarithmic hot scores, short-term rising velocity, comment Wilson scores, and controversial scores. New sorting is strictly timestamp-based and bypassed by the scoring worker.
- Sorted set publication: Updates Redis sorted sets including
feed:sub:{subreddit_id}:hot,feed:sub:{subreddit_id}:rising,feed:sub:{subreddit_id}:top, andfeed:sub:{subreddit_id}:controversial. - Asynchronous justification: A viral post receiving 30,000 votes per second cannot execute database row updates synchronously, meaning that offloading recalculation to background workers guarantees consistent API latencies.
Feed Service
Assembles personalized feeds by aggregating content across communities a user follows while strictly bounding compute overhead at 50M DAU:
- Active user bounding: Rather than refreshing all 50M users every 5 minutes (which would require 166,667 merges/sec), background merges are bounded strictly to the active user window (~2.5M concurrent users active within a 15-minute sliding window), requiring only ~8,300 merges/sec across horizontally sharded feed workers.
- Lazy recomputation for inactive users: Inactive users are not refreshed proactively, so their feeds are recomputed lazily on demand when they open the application.
- Bounded candidate pool: The merge job evaluates the user's top 50 subscribed subreddits, fetching the top 25 hot posts from each community's Redis sorted set (capping candidate IDs at 1,250). It interleaves posts by normalized hot score rather than naive alternation.
- Deduplication & caching: Deduplicates cross-posts using
crosspost_parent_idand caches the assembled feed atfeed:home:{user_id}with a 300-second TTL. - Stale-while-revalidate: If a cached feed expires during a user request, the stale feed is served immediately while an asynchronous task regenerates the fresh feed in the background.
- Membership changes: Joining or leaving a subreddit invalidates or marks the user's home feed cache dirty, prompting a refreshed merge on the next view.
Search Service (Elasticsearch)
Powers full-text exploration across forum posts, comments, and community directories:
- Multi-index architecture & shard routing: Segregates documents across
posts_index,comments_index, andsubreddits_index. Posts are routed bysubreddit_idto isolate community-scoped queries to a single shard, comments are routed bypost_idto co-locate discussion trees, and subreddits use edge-ngram analyzers for instant prefix autocomplete. - Query capabilities & ranking: Executes BM25 full-text scoring with title boosts, faceted filtering by community, author, post type, and creation time ranges, supporting sorting by relevance, recency (
new), or net engagement (top). - Asynchronous pipeline (
search-indexer): A dedicated Kafka consumer group consumespost-events,comment-events, and subreddit metadata to update Elasticsearch indices in near real time. Content removals and soft deletions immediately mark documents unavailable or purge them from search results.
Moderation Pipeline
Processes content events asynchronously through automated inspection models and community review workflows:
- Spam detection model: Evaluates incoming posts for link farming, automated bot phrase repetition, and abnormal new-account posting velocity.
- Toxicity classification: Flags abusive or harassing content for immediate human review or automated quarantine.
- AutoModerator engine: Evaluates custom per-subreddit regular expressions and karma thresholds, automating actions such as filtering forbidden keywords or enforcing minimum account age.
- Moderation queue: Aggregates user-submitted flags into a prioritized triage queue for community volunteer moderators.
- Automated enforcement & propagation: When content is removed by moderators or automated classifiers, a removal event is published. Downstream consumers immediately remove the item from Redis ranked sorted sets (
ZREM), invalidate active home feeds, purge CDN cache tags, and de-index the item in Elasticsearch.
Event Bus Design (Kafka)
The event bus decouples write-path producers from background consumers, absorbing traffic spikes seamlessly:
topics:
post-events:
description: "New post publications, content edits, soft deletions, and removals"
comment-events:
description: "New comment submissions, nested replies, edits, and deletions"
vote-events:
partitions: 128
partition_key: "entity_id (post_id or comment_id to preserve per-entity vote ordering)"
retention: "7 days"
replication_factor: 3
min_insync_replicas: 2
award-events:
partitions: 32
partition_key: "entity_id (post_id or comment_id)"
description: "Community awards and gilding carrying award_id, entity_type, entity_id, giver_user_id, award_type, and coin_cost"
producer_configuration:
acks: "all"
idempotence: true
payload_schema:
event_id: "uuid"
user_id: "int64"
entity_type: "post | comment"
entity_id: "int64"
old_direction: "1 | -1 | 0"
new_direction: "1 | -1 | 0"
delta: "int16"
timestamp: "int64"
consumer_groups:
vote-db-writer:
purpose: "Persist durable vote records to PostgreSQL source of truth (atomically checks processed_events table before upserting user_votes and applying score deltas)"
vote-counter:
purpose: "Apply atomic counter updates to Redis score hashes (score:post:{id}, score:comment:{id}) deduplicating event_id via Redis SET processed_events:counter:{event_id} 1 EX 604800 NX (7 days matching Kafka retention) with PostgreSQL processed_events ledger fallback"
ranking-worker:
purpose: "Recompute hot, top, rising, and controversial scores for Redis feed sorted sets (new sort uses created_at directly)"
search-indexer:
purpose: "Consume post-events, comment-events, and community metadata to update Elasticsearch indices near real time"
award-processor:
purpose: "Persist award records to PostgreSQL awards table, increment entity award_count, update Redis awards hash, and attribute karma"
moderation:
purpose: "Feed automated spam ML models, toxicity classifiers, and moderator report queues"
execution_paths:
synchronous_path: "Atomic Redis Lua CAS state transition, publish vote-events to Kafka with acks=all, return 200 within 50ms"
asynchronous_path: "Perform database batch writes, counter refreshes, search indexing, and feed recalculation off the critical path"
failure_semantics: "Kafka producer timeouts are treated as ambiguous outcomes, where event_id-based consumer idempotency makes replay or duplicate publication safe. The service reconciles Redis serving state with the durable event stream rather than assuming a timeout means the event was never committed. On definite pre-publish errors, Redis state is rolled back immediately."
dead_letter_queue: "vote-events-dlq routed after 3 retries, raising an alert if consumer lag exceeds 60 seconds"API Design
RESTful API contracts and TypeScript domain signatures for post publication, comment tree retrieval, voting, and community feed queries:
Client API Signatures
TypeScript interfaces defining operational signatures and parameters across services:
// Submit a new post to a subreddit community
createPost(
subreddit: string,
request: {
title: string;
content?: string;
postType: "text" | "link" | "image" | "video";
url?: string;
flair?: string;
crosspostParentId?: string;
}
): Promise<{ postId: string; createdAt: string }>;
// Retrieve a post along with ranked, nested comment trees
getPostWithComments(
postId: string,
sort: "best" | "top" | "new" | "controversial",
depth?: number,
limit?: number,
cursor?: string
): Promise<{ post: PostDetail; comments: CommentNode[]; hasMoreComments: boolean }>;
// Cast or adjust an upvote or downvote on a post or comment (asynchronous ingestion)
castVote(
entityType: "post" | "comment",
entityId: string,
direction: 1 | -1 | 0
): Promise<{
accepted: boolean;
voteState: 1 | -1 | 0;
eventId: string;
approximateScore?: number;
}>;
// Fetch ranked posts within a specific subreddit
getSubredditFeed(
subreddit: string,
sort: "hot" | "new" | "top" | "rising",
limit?: number,
cursor?: string
): Promise<{ posts: PostSummary[]; nextCursor: string | null }>;
// Add a root comment or reply to an existing comment tree
addComment(
postId: string,
content: string,
parentCommentId?: string
): Promise<{ commentId: string; path: string; depth: number; createdAt: string }>;
// Search across posts, comments, and subreddits
searchContent(
query: string,
entityType?: "all" | "post" | "comment" | "subreddit",
subreddit?: string,
sort?: "relevance" | "new" | "top",
limit?: number,
cursor?: string
): Promise<{ results: SearchResultItem[]; nextCursor: string | null }>;
// Give an award or gilding to a post or comment
giveAward(
entityType: "post" | "comment",
entityId: string,
awardType: "silver" | "gold" | "platinum" | "helpful",
message?: string,
anonymous?: boolean
): Promise<{ awardId: string; entityId: string; awardCount: number; coinBalance: number }>;Create Post
Publishes a new discussion thread or media reference within a specific subreddit:
POST /api/v1/subreddits/systemdesign/posts
Content-Type: application/json
Authorization: Bearer <user_token>
{
"title": "System Design Tips",
"content": "Here are my top tips for large scale discussions...",
"type": "text",
"flair": "Discussion"
}Get Post with Comments
Retrieves a post and its associated comment tree with cursor pagination:
GET /api/v1/posts/post-928374?sort=best&depth=5&limit=200
Authorization: Bearer <user_token>
HTTP/1.1 200 OK
Content-Type: application/json
{
"post": {
"post_id": "post-928374",
"subreddit": "systemdesign",
"title": "System Design Tips",
"score": 420
},
"comments": [
{
"comment_id": "comm-8472",
"content": "Great post!",
"score": 150,
"wilson_score": 0.942,
"depth": 0,
"replies": [
{
"comment_id": "comm-8473",
"content": "Agreed!",
"score": 42,
"wilson_score": 0.881,
"depth": 1,
"replies": []
}
]
}
],
"has_more_comments": true
}Vote on Entity
Submits or clears a vote on a target post or comment:
POST /api/v1/vote
Content-Type: application/json
Authorization: Bearer <user_token>
{
"entity_type": "post",
"entity_id": "post-928374",
"direction": 1
}
HTTP/1.1 200 OK
Content-Type: application/json
{
"accepted": true,
"vote_state": 1,
"event_id": "evt-7f9b2c3d-e45a",
"approximate_score": 421
}Get Subreddit Feed
Fetches a paginated, ranked feed of posts within a community:
GET /api/v1/subreddits/systemdesign/posts?sort=hot&cursor=cur_h93f82&limit=25
Authorization: Bearer <user_token>Add Comment
Appends a root comment or a reply to an existing comment tree:
POST /api/v1/posts/post-928374/comments
Content-Type: application/json
Authorization: Bearer <user_token>
{
"parent_comment_id": "comm-8472",
"content": "This is a great point!"
}Give Award
Bestows a community award or gilding on a post or comment:
POST /api/v1/posts/post-928374/awards
Content-Type: application/json
Authorization: Bearer <user_token>
{
"award_type": "gold",
"message": "Outstanding technical depth!",
"is_anonymous": false
}
HTTP/1.1 200 OK
Content-Type: application/json
{
"award_id": "awd-901842",
"entity_id": "post-928374",
"award_type": "gold",
"total_awards": 17,
"coin_balance": 1850
}Search Content
Executes multi-entity full-text queries across posts, comments, and subreddits:
GET /api/v1/search?query=distributed+caching&type=all&sort=relevance&limit=25
Authorization: Bearer <user_token>
HTTP/1.1 200 OK
Content-Type: application/json
{
"results": [
{
"entity_type": "post",
"entity_id": "post-928374",
"subreddit": "systemdesign",
"title": "System Design Tips: Distributed Caching",
"score": 420
},
{
"entity_type": "comment",
"entity_id": "comm-8472",
"post_id": "post-928374",
"body": "Redis cluster with consistent hashing resolves hot spot keys.",
"score": 150
}
],
"next_cursor": "eyJvZmZzZXQiOjI1fQ=="
}Common Error Responses
Standardized error payloads returned across search and forum endpoints:
400 Bad Request: invalid input, missing required fields, or malformed JSON payload 401 Unauthorized: missing or invalid authentication token or API key 403 Forbidden: authenticated caller lacks required permissions for this resource 404 Not Found: requested resource ID does not exist 409 Conflict: duplicate write or version conflict, retry with a unique idempotency key 422 Unprocessable Entity: syntactically valid request failed semantic business validation 429 Too Many Requests: rate limit quota exceeded, client should honor Retry-After header 500 Internal Error: unexpected server failure, retry safely with an idempotency key 503 Service Unavailable: downstream dependency is unavailable or overloaded, retry with exponential backoff 504 Gateway Timeout: search index shard responded slowly, narrow query parameters or retry
Data Model
Relational storage with materialized path indexes models comment hierarchies, while Redis sorted sets and hash structures power high-speed voting counters and feed ranking.
PostgreSQL: Posts (Sharded by subreddit_id)
Relational table storing post metadata, community ownership, and precomputed ranking scores:
CREATE TABLE posts (
post_id BIGINT PRIMARY KEY,
subreddit_id INT NOT NULL,
user_id BIGINT NOT NULL,
title VARCHAR(300) NOT NULL,
content TEXT,
url TEXT,
media_url TEXT,
post_type VARCHAR(20) DEFAULT 'text', -- 'text', 'link', 'image', 'video'
crosspost_parent_id BIGINT, -- references source post if crosspost
score INT DEFAULT 0,
upvotes INT DEFAULT 0,
downvotes INT DEFAULT 0,
comment_count INT DEFAULT 0,
award_count INT DEFAULT 0,
hot_score FLOAT DEFAULT 0,
is_locked BOOLEAN DEFAULT FALSE,
is_removed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL,
INDEX idx_subreddit_hot (subreddit_id, hot_score DESC),
INDEX idx_subreddit_new (subreddit_id, created_at DESC),
INDEX idx_subreddit_top (subreddit_id, score DESC)
);PostgreSQL: Comments (Materialized Path, Sharded by post_id)
Encodes hierarchical tree relationships in a dotted string path for single-query subtree retrieval:
CREATE TABLE comments (
comment_id BIGINT PRIMARY KEY,
post_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
parent_id BIGINT, -- null for top-level
path VARCHAR(1024) NOT NULL, -- e.g. "000001.000003.000007.00000F" (6-char base-36 segments, 2.17B siblings/parent)
depth SMALLINT NOT NULL,
child_seq INT DEFAULT 0, -- monotonic counter for concurrent sibling allocation
content TEXT NOT NULL,
score INT DEFAULT 0,
upvotes INT DEFAULT 0,
downvotes INT DEFAULT 0,
award_count INT DEFAULT 0,
wilson_score FLOAT DEFAULT 0,
controversial_score FLOAT DEFAULT 0,
is_removed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL,
INDEX idx_post_path (post_id, path),
INDEX idx_post_wilson (post_id, depth, wilson_score DESC),
INDEX idx_post_controversial (post_id, depth, controversial_score DESC),
INDEX idx_post_score (post_id, depth, score DESC)
);PostgreSQL: User Votes (Authoritative Source of Truth)
Durable relational table enforcing the one-vote-per-user-per-entity invariant via a composite primary key:
CREATE TABLE user_votes (
user_id BIGINT NOT NULL,
entity_type VARCHAR(10) NOT NULL, -- 'post' or 'comment'
entity_id BIGINT NOT NULL,
direction SMALLINT NOT NULL, -- 1 = upvote, -1 = downvote
updated_at TIMESTAMP NOT NULL,
PRIMARY KEY (user_id, entity_type, entity_id),
INDEX idx_entity_votes (entity_type, entity_id)
);PostgreSQL: Awards (Gilding and Community Appreciation)
Durable relational ledger storing awards and coins bestowed on posts and comments:
CREATE TABLE awards (
award_id BIGINT PRIMARY KEY,
entity_type VARCHAR(10) NOT NULL, -- 'post' or 'comment'
entity_id BIGINT NOT NULL,
giver_user_id BIGINT NOT NULL,
award_type VARCHAR(32) NOT NULL, -- 'silver', 'gold', 'platinum', 'helpful'
coin_cost INT NOT NULL DEFAULT 0,
message TEXT,
is_anonymous BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL,
INDEX idx_entity_awards (entity_type, entity_id, created_at),
INDEX idx_giver_awards (giver_user_id, created_at)
);PostgreSQL: Consumer Deduplication Ledger
Durable idempotency table preventing duplicate processing across Kafka consumer replays:
CREATE TABLE processed_events (
consumer_group VARCHAR(64) NOT NULL,
event_id UUID NOT NULL,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (consumer_group, event_id)
);Elasticsearch: Search Indices and Routing
Multi-index mappings and shard routing configurations across posts, comments, and subreddits:
indices:
posts_index:
routing: "subreddit_id (co-locates posts from same community on identical shard)"
mappings:
properties:
entity_id: { type: "keyword" }
entity_type: { type: "keyword" } # "post"
subreddit_id: { type: "keyword" }
subreddit_name: { type: "text", fields: { keyword: { type: "keyword" } } }
author_id: { type: "keyword" }
author_username: { type: "keyword" }
title: { type: "text", analyzer: "standard" }
body: { type: "text", analyzer: "standard" }
post_type: { type: "keyword" }
score: { type: "integer" }
comment_count: { type: "integer" }
award_count: { type: "integer" }
created_at: { type: "date" }
comments_index:
routing: "post_id (co-locates thread comments on identical shard)"
mappings:
properties:
entity_id: { type: "keyword" }
entity_type: { type: "keyword" } # "comment"
post_id: { type: "keyword" }
subreddit_id: { type: "keyword" }
author_id: { type: "keyword" }
author_username: { type: "keyword" }
body: { type: "text", analyzer: "standard" }
path: { type: "keyword" }
score: { type: "integer" }
award_count: { type: "integer" }
created_at: { type: "date" }
subreddits_index:
routing: "subreddit_id"
mappings:
properties:
entity_id: { type: "keyword" }
entity_type: { type: "keyword" } # "subreddit"
name: { type: "text", analyzer: "autocomplete", fields: { keyword: { type: "keyword" } } }
title: { type: "text" }
description: { type: "text" }
subscriber_count: { type: "integer" }
is_private: { type: "boolean" }
created_at: { type: "date" }Querying Comment Tree
Executes root comment selection and prefix-based subtree expansion:
-- Retrieve all top-level comments ordered by Wilson score ("best" sort)
SELECT * FROM comments
WHERE post_id = ? AND depth = 0
ORDER BY wilson_score DESC
LIMIT 25;
-- Retrieve top-level comments ordered by controversial score
SELECT * FROM comments
WHERE post_id = ? AND depth = 0
ORDER BY controversial_score DESC
LIMIT 25;
-- Retrieve nested replies under a specific comment branch using base-36 path prefix match
SELECT * FROM comments
WHERE post_id = ? AND path LIKE '000001.000003.%'
ORDER BY path
LIMIT 50;Redis: Vote Counts, Awards & User Votes
In-memory hashes and strings managing real-time counter updates, awards tallies, fast deduplication, and idempotency:
# Score and Counter Cache (per post)
Key: score:post:{post_id}
Type: Hash
Fields:
upvotes: 150
downvotes: 3
score: 147
hot_score: 123456.789
# Score and Counter Cache (per comment)
Key: score:comment:{comment_id}
Type: Hash
Fields:
upvotes: 42
downvotes: 2
score: 40
wilson_score: 0.881
# Awards Cache (per post or comment)
Key: awards:{entity_type}:{entity_id}
Type: Hash
Fields:
silver: 12
gold: 4
platinum: 1
helpful: 8
total: 25
# User Vote State (fast serving deduplication cache)
Key: user_votes:{user_id}:{entity_type}:{entity_id}
Type: String
Value: "1" (upvote) | "-1" (downvote)
TTL: 30 days (2,592,000 seconds, with cache misses falling back to PostgreSQL user_votes)
# Counter Consumer Dedup Key (fast-path idempotency matching 7-day Kafka retention)
Key: processed_events:counter:{event_id}
Type: String
Value: "1"
TTL: 7 days (604,800 seconds, falling back to the PostgreSQL processed_events ledger)Redis: Ranked Feeds
Sorted sets maintaining precomputed post rankings per subreddit and personalized home feeds:
# Subreddit Hot Feed (Precomputed Sorted Set)
Key: feed:sub:{subreddit_id}:hot
Type: Sorted Set
Members: post_id
Scores: hot_score (recomputed asynchronously by Ranking Worker)
# Subreddit Rising Feed (Short-Term Velocity Sorted Set)
Key: feed:sub:{subreddit_id}:rising
Type: Sorted Set
Members: post_id
Scores: rising_score (recomputed every 60-120 seconds for posts < 12 hours old)
# Subreddit New Feed (Chronological Sorted Set)
Key: feed:sub:{subreddit_id}:new
Type: Sorted Set
Members: post_id
Scores: created_at (written once on post creation with no worker recomputation needed)
# Personalized Home Feed per Active User
Key: feed:home:{user_id}
Type: Sorted Set
Members: post_id
Scores: normalized_hot_score
TTL: 300 seconds (5 minutes, rebuilt proactively for active users and lazily on read)Kafka Topics
Event topics decoupling content mutations from downstream cache and search consumers:
kafka_topics:
post-events:
description: "Post creation, text and media updates, and soft deletions"
comment-events:
description: "Comment creation, nested replies, edits, and deletions"
vote-events:
description: "Upvotes, downvotes, and vote cancellations carrying old and new state"
award-events:
description: "Community awards and gilding carrying entity references, award types, and coin transactions"Fault Tolerance
Mitigation strategies for vote manipulation, thread retrieval timeouts, and ranking staleness under high concurrency:
General Resilience Matrix
| Concern | Solution |
|---|---|
| Vote count accuracy | Replicated Kafka provides durable, replayable event storage under configured acknowledgement and replication guarantees (replication factor 3, min.insync.replicas 2, acks=all), while Redis counters are periodically reconciled against durable PostgreSQL totals |
| Comment tree corruption | Materialized path storage with atomic per-parent sequence allocation prevents path collision and broken ancestor links |
| Hot score staleness | Background jobs recalculate community scores every 5 minutes, while streaming consumers apply immediate updates to viral posts |
| Database hot spots on popular posts | Read replicas handle post text retrieval, while Redis absorbs high-throughput vote counter reads |
| Ranking computation worker failure | Slightly stale rankings are served directly from Redis sorted sets to preserve platform availability during worker recovery |
| Redis cache failure / cold start | Redis operates as an ephemeral serving layer, so if feed or counter caches are lost, subreddit feeds are rebuilt from PostgreSQL post indexes, vote counters from durable vote records, and home feeds lazily on the next user view |
Specific: Handling Vote Manipulation
Defenses designed to neutralize bot rings and automated upvoting campaigns:
- Unique vote constraints: Strictly enforces one vote per user per entity using unique composite database primary keys on
(user_id, entity_type, entity_id)and Redis user vote keys. - Rate limiting: Restricts user accounts to a maximum of 100 vote actions per minute to mitigate automated bot scripts.
- Vote fuzzing: Introduces minor random score fluctuations on public displays to prevent bot creators from verifying whether their automated votes succeeded.
- Shadow banning: Silently ignores votes from flagged abusive accounts so that bad actors receive successful HTTP responses while their input is excluded from score aggregation.
- IP clustering detection: Identifies coordinated voting rings by flagging abnormal spikes in identical voting patterns emerging from shared IP subnets.
Specific: Redis & Kafka Failure Recovery
Guarantees safe state recovery without data corruption or phantom mutations across serving and durable tiers:
- Ambiguous timeout reconciliation: Definite pre-publish errors trigger immediate Redis rollback and an HTTP 503 error. For publish timeouts where broker commit status is ambiguous, the Vote Service avoids destructive rollback because downstream consumers use
event_iddeduplication to safely absorb retries or late deliveries, while background reconcilers ensure Redis serving keys match PostgreSQL durable state. - Event-level delta deduplication: While user-vote state upserts are naturally idempotent on
(user_id, entity_type, entity_id), relative score deltas are not. Bothvote-db-writer(via transactionalprocessed_eventsin PostgreSQL) andvote-counter(via RedisSET processed_events:counter:{event_id} 1 EX 604800 NX, matching Kafka's 7-day retention window with PostgreSQL ledger fallback on cache miss) deduplicate byevent_idprior to applying score deltas, eliminating duplicate counter increments during Kafka partition rebalances or consumer replays. Redis counters remain non-authoritative and are periodically re-synchronized against PostgreSQL durable scores. - Authoritative database backstop: When Redis user vote keys expire after 30 days or evict under memory pressure, subsequent vote requests consult PostgreSQL
user_voteson cache miss to rehydrate the state before applying any new transition. - Counter reconciliation: Background reconciliation jobs periodically compare Redis hash counters against the count of active rows in PostgreSQL
user_votes, fixing any transient drift caused by ungraceful Redis failovers.
Additional Considerations
Production patterns covering lazy comment loading, subreddit moderation tooling, cross-posting mechanics, hierarchical caching, and interview delivery strategies.
Lazy Comment Loading and Deep Thread Pagination
Loading massive comment trees requires bounded queries and incremental UI hydration:
- Top-level truncation: For posts with over 10,000 comments, the client initially fetches only the top 200 comments ranked by Wilson score at depth 0.
- Subtree expansion: Expanding a discussion branch triggers a query filtering on the path prefix such as
WHERE post_id = ? AND path LIKE '000001.000003.%' LIMIT 50, avoiding recursive CTE overhead entirely. - Reply indicators: Each comment record stores a precalculated reply count, prompting clients to display an interactive expansion link.
- Cursor pagination: Deep threads use lexicographical cursor pagination over the path column to ensure stable pagination even as new replies arrive.
Subreddit Moderation Architecture
Empowers community moderators while automating high-volume policy enforcement:
- Moderator capabilities: Tools to remove offending content, ban repeat offenders, establish community-specific submission guidelines, and configure automated filters.
- AutoModerator rules engine: A deterministic rule evaluator matching regular expressions, account age thresholds, and minimum user karma requirements.
- Prioritized triage queue: Aggregates community member reports into an actionable review queue prioritized by report severity and frequency.
Cross-Posting Architecture
Supports sharing discussions across multiple communities while keeping engagement isolated:
- Independent records: A cross-post creates a new row in the posts table referencing the source content through
crosspost_parent_id. - Engagement isolation: Vote scores, comment trees, and moderation actions remain fully independent within each subreddit.
Multi-Tier Caching Architecture
Tiered caching layers minimize origin database load across dynamic voting and feed retrieval paths:
# Multi-Tier Caching Hierarchy
l1_client_cache:
target: "Mobile applications and web browsers"
ttl: "60 seconds"
purpose: "Local cache of rendered posts and optimistic user vote state"
l2_edge_cdn:
target: "CDN edge servers"
ttl: "30 seconds"
purpose: "Public subreddit front-page feeds for unauthenticated visitors"
l3_redis_cluster:
target: "Distributed in-memory Redis cluster"
ttl: "300 seconds"
purpose: "Ranked feed sorted sets, hot post score hashes, and user vote records"
l4_database:
target: "PostgreSQL primary and read replica pool"
ttl: "Durable storage"
purpose: "Authoritative relational source of truth"Related Problems and Concepts
Threaded discussion and feed ranking patterns connect directly to feed generation in Design a News Feed System and social timeline fan-out in Design Twitter Timeline and Search. Direct messaging and chat infrastructure excluded from this scope is explored in Design a Real-Time Chat System, while high-volume vote counter scaling mirrors distributed counter designs in Like Count (High-Profile). Review foundational concepts in SQL vs NoSQL, Caching Patterns and Invalidation, Sharding and Partitioning, Redis Patterns for Interview Systems, Back-of-the-Envelope Estimation, and System Design Interview Patterns.
Interview Walkthrough
- 25-Minute Interview Strategy
Prioritize core data structures and asynchronous decoupling before exploring staff-level moderation and anti-abuse mechanics.
- Requirements and Scope Definition (5 min)
- Comment Tree Modeling: Materialized Path vs Adjacency List (6 min)
- Asynchronous Vote Pipeline and Redis Counter Ingestion (5 min)
- Ranking Algorithms and Redis Sorted Set Feed Generation (5 min)
- Multi-Tier Caching, Edge CDN, and Fault Tolerance (4 min)
- Clarify ranking models early by contrasting time-decayed logarithmic hot scores against Wilson confidence scoring for comments, establishing why each sort mode demands separate precomputation keys.
- Model comment trees with materialized paths in PostgreSQL, demonstrating how dot-separated lineage paths enable single-query subtree fetches while keeping inserts efficient.
- Structure the vote ingestion pipeline around Kafka and Redis atomic counters to avoid catastrophic row-level locking on popular threads in the relational database.
- Leverage Redis sorted sets per community and sort mode to achieve sub-millisecond in-memory lookups, ensuring platform-level p99 feed delivery under 200 ms while background workers handle asynchronous recomputations.
- Apply Back-of-the-Envelope Estimation to quantify write and read throughput: 50M daily active users submitting 5M posts, 50M comments, and 500M votes generate 555M write operations/day (~6,424/sec avg, 35K peak). At a 100:1 read-to-write ratio, total read traffic reaches 55.5B reads/day (~642K avg QPS), justifying asynchronous batching before hitting PostgreSQL and caching over 95% of reads in Redis and edge CDNs.
- Establish multi-layer caching across client, CDN, Redis, and database tiers, tailoring time-to-live expirations to the staleness tolerance of each sorting mode.
- Avoid the classic pitfall of calculating hot scores synchronously during feed read requests, which inevitably triggers p99 latency spikes on viral discussions.
Engineering Trade-offs
Key architectural decisions contrasting real-time vote ingestion pipelines, hierarchical tree storage engines, and algorithmic scoring mechanisms:
Vote Counting: Redis INCR vs Database Counters vs Log-Based Aggregation
Managing over 500M daily votes across viral content requires balancing write concurrency, persistence durability, and query latency:
Scale context: 500M votes/day (~6,000/sec average, 30,000/sec peak)
555M total writes/day, with 55.5B reads/day at a 100:1 read/write ratio
Option 1: Direct Synchronous Database Updates
Query: UPDATE posts SET score = score + 1 WHERE post_id = ?
Write bottleneck: Viral posts receiving thousands of concurrent votes lock rows
Contention penalty: Severe database row-level locking degrades p99 latency
Conclusion: Unviable for high-concurrency viral threads
Option 2: Atomic Redis Lua CAS with Asynchronous Kafka Pipeline (Recommended)
Counter command: HINCRBY score:post:{post_id} score {delta}
User state: Atomic Lua CAS on user_votes:{user_id}:post:{post_id} with 30-day sliding TTL
Net delta rules:
- 0 -> +1 (new upvote): delta = +1
- 0 -> -1 (new downvote): delta = -1
- +1 -> -1 (flip to downvote): delta = -2
- -1 -> +1 (flip to upvote): delta = +2
- +1 -> 0 (cancel upvote): delta = -1
- -1 -> 0 (cancel downvote): delta = +1
Advantages:
- Atomic Lua script prevents race conditions between concurrent requests from the same user
- O(1) in-memory operations handle tens of thousands of updates per second with zero row locks
- Immediate 200/202 response returning accepted state, while PostgreSQL persistence runs asynchronously
Failure semantics & durability:
- Definite pre-publish failures trigger Redis rollback, whereas publish timeouts are treated as ambiguous and resolved via event_id consumer deduplication and stream reconciliation rather than blind rollback
- Downstream consumers deduplicate event_id before applying score deltas: vote-db-writer writes to PostgreSQL processed_events table, and vote-counter checks Redis with a 7-day TTL (604,800s matching Kafka retention) backed by the database ledger, eliminating duplicate counter increments during Kafka partition rebalances or 7-day replays
- Redis counter cache is explicitly non-authoritative, with periodic reconciliation jobs synchronizing Redis score hashes against PostgreSQL posts.score and comments.score durable truth
- PostgreSQL user_votes unique constraint on (user_id, entity_type, entity_id) is the durable source of truth
- Cache misses or expired keys fall back to PostgreSQL before accepting new state
Trade-off: In-memory counters are volatile until Kafka consumers persist them
Option 3: Kafka Event Log with Streaming Aggregation (Apache Flink)
Pipeline: Vote event -> Kafka topic -> Flink tumbling window -> Redis and DB update
Advantages:
- Strict durability with full replayability from the Kafka event log
- Enables accurate historical score recalculation
Trade-off: Introduces 5 to 30 seconds of processing latency and operational complexity
Production Decision: Atomic Redis Lua CAS paired with Kafka event publishing (acks=all) and asynchronous PostgreSQL batch writes. PostgreSQL user_votes is the authoritative backstop, and Redis counters are reconciled against durable state.Comment Tree Storage: Materialized Path vs Adjacency List vs Nested Sets
Discussion threads frequently exceed 10,000 comments spanning more than 20 nesting levels:
Option 1: Adjacency List (parent_id references)
Schema: comments(comment_id, post_id, parent_id, content, score)
Full thread query: Recursive common table expression (WITH RECURSIVE)
Trade-offs:
- Simple relational schema and fast O(1) comment inserts
- Recursive CTE queries become computationally prohibitive on deep 10,000-comment trees
- Application-level iterative hydration causes severe N+1 query storms
Option 2: Materialized Path (Recommended)
Schema: comments(comment_id, post_id, path, depth, child_seq, wilson_score, controversial_score, content, score)
Example path: "000001.000003.000007.00000F" representing ancestor lineage from root (6-character base-36 segments, 2.17B siblings/parent)
Subtree query: WHERE post_id = ? AND path LIKE '000001.000003.%'
Advantages:
- Single query retrieves entire subtrees without recursive joins
- Lexicographical sorting on path yields natural pre-order display traversal for UI rendering
- Encodes depth directly, simplifying UI indentation
- Composite B-tree indexing on (post_id, path) isolates subtree queries to efficient single-index scans
- Atomic per-parent sequence allocation (child_seq) prevents path collision during concurrent replies
- Ranking consistency: Top-level comments use idx_post_wilson (depth=0, wilson_score DESC), while child branches expand by path prefix
Trade-off: Moving comment branches requires updating descendant paths, which is exceptionally rare in discussion forums
Option 3: Nested Sets (Left and Right Boundary Values)
Schema: comments(comment_id, post_id, lft, rgt, content)
Trade-offs:
- Fast read queries using boundary checks (WHERE lft > parent.lft AND rgt < parent.rgt)
- Inserting a reply requires renumbering boundary values across the entire table
- Catastrophic write amplification renders this model unusable for active discussions
Production Decision: Materialized Path architecture. Initial requests load the top 200 comments ranked by wilson_score, while user expansion triggers lazy subtree fetches using path prefix queries.Ranking Algorithms: Wilson Score vs Hacker News vs Reddit Logarithmic Decay
Comparing content ranking algorithms across engagement velocity and fairness:
Option 1: Net Vote Difference (Upvotes - Downvotes)
Calculation: net_score = upvotes - downvotes
Weakness:
- Heavily favors older content because accumulation increases over time
- A post from 5 years ago with 100,000 upvotes permanently crowds out fresh discussions
Option 2: Hacker News Time-Decay Gravity Model
Calculation: score = (points - 1) / (age_in_hours + 2)^1.8
Weakness:
- Simple and effective for post submission ranking
- Lacks downvote weighting and cannot assess confidence across small sample sizes
Option 3: Wilson Score Confidence Interval (Recommended for Comments)
Calculation: Lower bound of 95% confidence interval for a Bernoulli parameter
Advantages:
- Accurately balances positive vote ratio against total volume confidence
- Prevents a comment with 1 upvote (100% positive) from outranking a comment with 100 upvotes and 2 downvotes (98% positive)
- Naturally deprioritizes heavily controversial 50/50 vote distributions due to wider uncertainty intervals
- Stored in the materialized wilson_score column and indexed for instant top-level retrieval
Option 4: Reddit Hot Ranking Model (Recommended for Posts)
Calculation: log10(max(|ups - downs|, 1)) + sign(ups - downs) * (seconds_since_epoch / 45000)
Key properties:
- Logarithmic vote scaling: Each 10x increase in net upvotes yields 1 additional score unit, preventing hyper-viral posts from dominating forever
- Temporal decay: Adds 1 score unit every 12.5 hours (45,000 seconds), ensuring fresh high-velocity posts naturally overtake older discussions
Option 5: Chronological New Sorting (Strict Timestamp Ordering)
Calculation: score = created_at timestamp (Unix milliseconds or Snowflake ID timestamp)
Key properties:
- Evaluated once upon post or comment submission into feed:sub:{id}:new
- Zero background worker recomputation required, strictly distinguishing New from engagement-based Hot/Top/Controversial rankings
Option 6: Rising Ranking (Short-Term Engagement Velocity)
Calculation: score = (delta_ups_1h * 2 + delta_comments_1h * 3) / (age_in_hours + 0.5)^1.5
Target window: Posts created within the last 6 to 12 hours
Key properties:
- Measures short-term engagement burst rather than cumulative vote mass
- Enables breakout discussions to surface quickly before accumulating enough votes to reach the Hot feed
- Maintained in Redis feed:sub:{id}:rising sorted sets by high-frequency Ranking Worker evaluationReview
How helpful was this walkthrough?
Click a star to rate. We actively use this feedback to refine and update our system design content.
Discussion
Share your thoughts, ask questions, or help others.