Design a Comment Service API

Threaded comments, pagination by cursor, moderation hooks, the reaction subsystem. The CRUD that scales to millions of nodes.

System Intermediate
14 min read
comments api-design threading pagination

Context#

A comment service is the most-deployed piece of social infrastructure on the internet. News articles have it; product pages have it; pull requests, design docs, podcasts, photo albums — every platform that lets users say something about a thing eventually owns one. Disqus built an entire company around being it as a service. Reddit’s threading model is the gold standard for what a popular comment tree looks like at scale. GitHub’s PR review comments are a comment service with an attached state machine.

The temptation in an interview is to dismiss this as “just CRUD” — four endpoints, a database table, done. That misses the actual difficulty:

  • Threading: replies-to-replies create an unbounded tree. How do you paginate one?
  • Reads vs writes: comment systems are 100:1 read-heavy. The write path can be slow; the read path cannot.
  • Moderation: every public surface needs a flag → review → action loop, often async.
  • Reactions: thumbs-up / heart / laugh — a sub-entity with its own lifecycle and aggregate counts.
  • Edits and deletes: do you tombstone, hard-delete, or soft-delete? The choice has legal and UX implications.

The interviewer’s hidden objectives:

  • Can you model threading without an unbounded recursive query?
  • Do you pick cursor pagination correctly given live writes?
  • Can you describe the moderation pipeline as a state machine and an async queue, not a synchronous block?
  • Can you size the reaction aggregate so it doesn’t hot-spot on viral comments?
  • Can you defend an edit-history model and an author-vs-mod delete distinction?

Reference: Reddit, Disqus, Hacker News, GitHub PR reviews.

Requirements (functional and non-functional)#

Functional — in scope:

  • Create a top-level comment on a thread (e.g. an article, a video, a PR).
  • Reply to an existing comment (creates a child).
  • Edit a comment within an edit window.
  • Delete a comment (author tombstone or mod-action hide).
  • Add or remove a reaction (thumbs-up, heart, etc.) on a comment.
  • List comments on a thread, paginated; reply-trees lazily expanded.
  • Flag a comment for moderation review.

Functional — out of scope:

  • Identity / auth — assume an upstream identity service issues JWTs.
  • Notification delivery — pushed to a separate service via async event.
  • Rich-text rendering / sanitisation — handled by a content pipeline upstream of write.
  • Search inside comments — covered by the search service via async indexing.

Non-functional:

  • Latency: read list <= 200 ms p95, write <= 300 ms p95.
  • Throughput: 50k reads/s sustained per thread (viral hotspot), 5k writes/s aggregate.
  • Availability: 99.95% on the read path; 99.9% on the write path.
  • Consistency: writes are read-your-writes for the author; eventually consistent for other viewers within 1 s.
  • Storage: bounded by retention policy; comments are kept indefinitely by default.

Use case diagram#

┌──────────────┐ ┌──────────────┐
│ Commenter │ │ Moderator │
└──────┬───────┘ └──────┬───────┘
│ │
┌───────────────┼───────────────┐ ┌───────┴────────┐
▼ ▼ ▼ ▼ ▼
[post reply] [react] [edit/delete] [review flag] [hide/restore]
│ │ │ │ │
└───────────────┴───────┬───────┴───────┴────────────────┘
┌────────────────────────────┐
│ Comment Service API │
└────────────┬───────────────┘
┌──────────┴──────────┐
▼ ▼
[Viewer] read tree [Async] events out

Three actors: commenter, moderator, viewer. The viewer is read-only and the dominant load.

Class diagram#

┌───────────────────────┐
│ CommentService │
├───────────────────────┤
│ create(req): Comment │
│ reply(req): Comment │
│ edit(id, body): Comment│
│ delete(id, by): void │
│ list(threadId, cur) │
│ react(id, type) │
│ flag(id, reason) │
└──────────┬────────────┘
│ owns
┌───────────────────────┐ ┌─────────────────────┐
│ Thread │ 1 ─── * │ Comment │
├───────────────────────┤ ├─────────────────────┤
│ id │ │ id │
│ subject_type │ │ thread_id │
│ subject_id │ │ parent_id? │
│ comment_count │ │ depth (0..N) │
│ created_at │ │ author_id │
└───────────────────────┘ │ body │
│ status │
│ created_at │
│ edited_at? │
└────┬────────────┬───┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Reaction │ │ Moderation │
├─────────────────┤ ├─────────────────┤
│ comment_id │ │ comment_id │
│ user_id │ │ flag_count │
│ type (👍/❤/😂) │ │ reviewed_by? │
│ created_at │ │ action │
└─────────────────┘ └─────────────────┘

Thread is the comment-container — a polymorphic anchor referenced via (subject_type, subject_id). Comment.parent_id is the threading link. Reaction is a separate row per (comment, user, type) triple; aggregate counts live denormalised on Comment for read speed. Moderation tracks flagging state separately so a flag action doesn’t lock the parent row.

Sequence diagram (key flows)#

The post-reply flow — the canonical write path:

Client CommentAPI DB EventBus Cache
│ POST /threads/{t}/comments │ │ │
│──────────────────►│ │ │ │
│ │ validate │ │ │
│ │ check rate│ │ │
│ │ insert │ │ │
│ │──────────►│ │ │
│ │ comment │ │ │
│ │◄──────────│ │ │
│ │ emit event│ │ │
│ │─────────────────────────►│ │
│ │ invalidate thread cache │ │
│ │ ──────────────────────────────────────►│
│ 201 + Comment │ │ │
│◄──────────────────│ │ │
[moderation,
notifications,
search-index]

The list flow — the dominant read path:

Client CommentAPI Cache DB
│ GET /threads/{t}/comments?cursor=...
│──────────────────►│ │ │
│ │ cache key │ │
│ │─────────────►│ │
│ │ hit? │ │
│ │◄─────────────│ │
│ │ miss │ │
│ │ query top-N │ │
│ │ where depth=0│ │
│ │─────────────────────────────►│
│ │ top comments + reply_count │
│ │◄─────────────────────────────│
│ │ set cache │ │
│ │─────────────►│ │
│ 200 + page │ │ │
│◄──────────────────│ │ │

The moderation flow — async, decoupled from the write:

Flagger CommentAPI ModQueue Moderator
│ POST /comments/{c}/flag │ │ │
│─────────────────►│ │ │ │
│ │ insert flag │ │
│ │ enqueue if threshold ►│ │
│ 202 Accepted │ │ │ │
│◄─────────────────│ │ │ │
│ │ │
│ (later) GET │ │
│ /moderation/queue──────────►│
│ │ │
│ POST /moderation/{c}/action│
│ ◄─────────────────────────│
│ │ apply action ◄────────│ │
│ │ Comment.status = Hidden │

Activity diagram (for non-trivial state)#

Comment.status is the meaningful state machine:

[author: POST]
┌──────────┐
│ Active │◄────┐
└────┬─────┘ │ un-hide
│ │ (mod restore)
┌──────────┼───────────┘
│ │
edit │ │ author delete
▼ ▼
┌─────────┐ ┌─────────┐
│ Edited │ │ Deleted │ (tombstone — body cleared,
│ │ │ │ structure preserved for thread)
└────┬────┘ └─────────┘
│ flag threshold → mod review → hide
┌─────────┐
│ Hidden │
│ (mod) │
└─────────┘

The tombstone matters: deleting a comment that has children must preserve the parent slot so the tree doesn’t collapse, mirroring Reddit’s “[deleted]” placeholder. The body is cleared; the Comment row stays.

API implementation#

Endpoint catalogue#

MethodPathPurpose
POST/v1/threads/{t}/commentsCreate top-level comment
POST/v1/comments/{id}/repliesReply to a comment
PATCH/v1/comments/{id}Edit body
DELETE/v1/comments/{id}Tombstone (author) or hide (mod)
GET/v1/threads/{t}/commentsList top-level comments, cursor-paginated
GET/v1/comments/{id}/repliesExpand a subtree, cursor-paginated
POST/v1/comments/{id}/reactionsAdd reaction
DELETE/v1/comments/{id}/reactions/{type}Remove reaction
POST/v1/comments/{id}/flagFlag for moderation
GET/v1/moderation/queue(Mod-only) review queue
POST/v1/moderation/{id}/action(Mod-only) hide / restore / dismiss

Two pagination cursors are in play: one over top-level comments per thread, one over replies per parent. They share format (opaque base64 of (score, id) or (created_at, id)).

Threading shape#

We use the adjacency list model: each row carries parent_id. Depth is materialised on insert. To bound query cost, the list endpoint returns top-level comments with reply_count and the first ~3 replies inline; deeper subtrees are fetched on demand via GET /v1/comments/{id}/replies. The hard limit on depth is 7 — past that the tree is visually unreadable and the UI flattens further nesting.

OpenAPI schema (excerpt)#

OpenAPI 3.1 — Comment API
paths:
/v1/threads/{threadId}/comments:
post:
operationId: createComment
parameters:
- { name: threadId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [body]
properties:
body: { type: string, minLength: 1, maxLength: 10000 }
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/Comment'
'400': { description: Invalid body }
'429': { description: Too many writes }
get:
operationId: listComments
parameters:
- { name: threadId, in: path, required: true, schema: { type: string } }
- name: cursor
in: query
schema: { type: string }
- name: page_size
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
- name: sort
in: query
schema:
type: string
enum: [top, new, old]
default: top
responses:
'200':
description: Page of comments
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items: { $ref: '#/components/schemas/Comment' }
next_cursor: { type: string, nullable: true }
/v1/comments/{id}/reactions:
post:
operationId: addReaction
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [type]
properties:
type:
type: string
enum: [thumbsup, heart, laugh, sad, angry]
responses:
'200': { description: Reaction added (idempotent) }
components:
schemas:
Comment:
type: object
required: [id, thread_id, author_id, body, status, created_at, depth]
properties:
id: { type: string }
thread_id: { type: string }
parent_id: { type: string, nullable: true }
author_id: { type: string }
body: { type: string }
status:
type: string
enum: [Active, Edited, Deleted, Hidden]
depth: { type: integer }
created_at: { type: string, format: date-time }
edited_at: { type: string, format: date-time, nullable: true }
reply_count: { type: integer }
reactions:
type: object
additionalProperties: { type: integer }
first_replies:
type: array
items: { $ref: '#/components/schemas/Comment' }

Cursor payload#

The cursor encodes (sort_key, id) so that pagination is stable under concurrent writes. For sort=new, the cursor is base64({"t": "2026-05-30T12:00:00Z", "id": "abc"}). The server returns rows strictly before this tuple in the sort order.

Idempotency#

Reaction add/remove is idempotent by (user_id, comment_id, type) — adding the same reaction twice is a no-op. Comment create accepts an optional Idempotency-Key header; identical retries within 24 h return the original Comment and a 201 with Idempotent-Replayed: true header. See the idempotency-in-api-design building-block writeup for the full rationale.

Client samples — three languages#

The reply-with-edit flow shown in three languages.

Comment client — Python
import requests, uuid
API = "https://api.example.com"
TOKEN = "Bearer eyJhbGciOi..."
def post_reply(parent_id, body):
return requests.post(
f"{API}/v1/comments/{parent_id}/replies",
json={"body": body},
headers={
"Authorization": TOKEN,
"Idempotency-Key": str(uuid.uuid4()),
},
timeout=2,
).json()
def edit(comment_id, body):
return requests.patch(
f"{API}/v1/comments/{comment_id}",
json={"body": body},
headers={"Authorization": TOKEN},
timeout=2,
).json()
def react(comment_id, kind):
requests.post(
f"{API}/v1/comments/{comment_id}/reactions",
json={"type": kind},
headers={"Authorization": TOKEN},
).raise_for_status()
c = post_reply("abc123", "Solid analysis. The pagination angle is underrated.")
edit(c["id"], c["body"] + " (edit: typo)")
react(c["id"], "thumbsup")

Latency budget#

The 200 ms p95 read budget breaks down as:

PhaseBudgetNotes
Gateway (rate limit, auth)5 msJWT cached
Cache lookup2 msRedis hit on hot threads (>90%)
DB query on miss100 ms p95Index on (thread_id, parent_id, sort_key)
Reaction aggregationincludedDenormalised on Comment
Serialize + transport20 msJSON, ~30 KB
Margin for tail70 msSlow shard / GC pause
Total~200 msAt or under the budget

The write path is allowed 300 ms — the extra 100 ms is for the synchronous fanout (cache invalidate + event emit + denormalised parent update).

Trade-offs and extensions#

DecisionWhyCost if requirements change
Adjacency list, not nested setInsert is O(1); read needs careRecursive subtree reads are multi-query
Tombstone on deletePreserves tree structureBody is irrecoverable; legal requests are expensive
Reaction counts denormalisedReads stay O(1)Write path is heavier; eventual consistency on count
Cursor by (sort_key, id)Stable under writesCan’t jump to “page 47”
Async moderation pipelineDoesn’t block writesBad content visible briefly
Top-3 replies inline, rest on demandTwo-phase load is acceptable UXIncreases endpoint count by one
Depth cap at 7Bounds query / UX complexitySome platforms (Reddit) go unbounded with collapse

A clean contrast:

Threaded view (Reddit-style)

  • Tree expand/collapse
  • Per-subtree pagination
  • Two query patterns: top-level + subtree
  • Reaction tally per node
  • Higher cognitive load

Flat chronological (Hacker News-style)

  • Single sorted list
  • Single cursor
  • One query pattern
  • “In reply to” prefix only
  • Easier for high-velocity feeds

Likely follow-up extensions:

  • Mentions and notifications. Parse @username in body server-side; emit a mention event. Notification delivery is out of scope; the event bus picks it up.
  • Rich-text and attachments. Body becomes a JSON document (matching the rich-content building block); attachments flow through the file service via signed URLs.
  • Per-thread moderation policy. Some threads are pre-moderated (queue before publish), others post-moderated. Add a moderation_mode field on Thread.
  • Audit log. Every state transition writes to an immutable log for legal compliance.

Mock interview follow-ups#

  • “How do you handle 100k comments on a viral post?” — Cursor pagination + denormalised counts + edge cache on the top-level list. Subtree expansion is rare; pay for it on demand.
  • “What happens if two users add the same reaction at the same time?” — Idempotent by (user, comment, type); the underlying INSERT ... ON CONFLICT DO NOTHING is the canonical pattern.
  • “Why not nested set / closure table?” — Insert cost. Closure tables make insert O(depth); on a 5k QPS write path that’s prohibitive. Adjacency list keeps insert at O(1), reads stay tractable with the right index.
  • “What’s the consistency model?” — Read-your-writes for the author via session-pinning to the primary DB. Other viewers see eventual consistency within 1 s as caches invalidate and replicas catch up.
  • “How do edits affect notifications?” — They don’t, in v1. Mentions added in an edit fire a new event; mentions removed are not retracted. This matches Slack and Discord behaviour.
  • “How do you protect against spam?” — Per-user rate limit at the gateway + bayesian-classifier check before persistence + moderation flag aggregation; community downvote also surfaces to the queue.
  • “What if a comment violates GDPR / right-to-be-forgotten?” — Hard delete with a special admin endpoint, audit-logged. Replaces the body with a tombstone and drops the row after retention.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.