Design: Document Comments (Google-Docs Style)
TL;DR
Threaded comments anchored to selections in a document. Looks simple, hides three hard problems: anchor stability (the document edits underneath the comment), threaded conversation UX (replies, resolves, mentions, real-time updates), and scale (a doc with thousands of comments needs virtualization and intelligent loading). The senior answer leads with anchor strategy — that’s the part that breaks under collaboration.
Requirements to clarify
- Anchor model. Pinned to character range? Pinned to a structural element (paragraph, block)?
- Document type. Plain text, rich text (ProseMirror/Slate), PDF, video timeline, image regions? Anchor mechanism differs.
- Concurrent editing. Is the doc itself collaboratively edited (see 05_collaborative_editor.md)? If yes, anchors must survive concurrent edits.
- Resolved comments — hidden by default, accessible via toggle, kept forever for audit?
- Mentions / notifications — @user mention triggers email/push?
- Permissions — who can view, comment, resolve, delete?
- History — can you see all changes to a thread? Edit comments after posting?
The anchor problem
A user highlights “the brown fox” at characters 20-32 and adds a comment. Later:
- Someone deletes “quick” before that — characters shift; absolute index 20-32 now points at the wrong text.
- Someone deletes the entire “brown fox” — what happens to the comment? Orphaned? Deleted? Show at the deletion point?
- Someone replaces “brown fox” with “lazy dog” — should the comment still apply?
The three anchor strategies:
| Strategy | How | Survives edits? | Survives deletion? |
|---|---|---|---|
| Absolute char index | (start, end) |
no | n/a — points to wrong text |
| Structural anchor | { blockId, offsetInBlock } or { paragraphId, range } |
yes if block stable | becomes orphan if block deleted |
| Relative position (CRDT) | anchored to a text run via the CRDT’s relative position type | yes | survives concurrent edits; deletion produces an orphan |
Yjs ships a Y.RelativePosition type explicitly for this. Each comment stores { start: relPos, end: relPos } instead of character offsets. When you need to render, convert relPos → absolute index using the current Y.Doc state.
For non-CRDT docs, structural anchors with element IDs + offsets are the practical pick. Each block (paragraph, heading, list item) has a stable id; the comment anchors { blockId, charStart, charEnd }. Edits within a block re-snap to the block’s new content via fuzzy match if the exact range is gone.
API contract
GET /api/docs/:id/comments
→ {
"threads": [
{
"id": "th_1",
"anchor": { "blockId": "p_42", "start": 12, "end": 25 } | null, // null = orphan
"status": "open" | "resolved",
"messages": [
{ "id": "m_1", "authorId": "u_ada", "body": "...", "createdAt": "...", "mentions": ["u_bob"] }
]
}
]
}
POST /api/docs/:id/comments → create thread
POST /api/threads/:id/messages → reply
PATCH /api/threads/:id → resolve / reopen
DELETE /api/messages/:id → soft delete (preserve thread shape)
Real-time updates over WS (subscribed to doc:{id} channel) for thread create / message / resolve events.
Client data model
- Threads keyed by
["comments", docId]in TanStack Query. - Anchor resolution layer — a function
resolveAnchor(anchor) → DOM range | nullthat runs on every render or when the doc changes. Memoize per-thread. - Active selection — current text selection in the doc, used to position the “+ comment” affordance.
- Open thread — the currently expanded thread (URL state, deep-linkable).
Rendering: marginalia vs popovers
Two UX patterns, often combined:
- Marginalia — comment threads in the right margin, anchored visually to their position in the doc. Use absolute positioning + an algorithm to stack overlapping comments vertically (no two comments can share the same Y; later ones push down).
- Popovers / sidebars — click on a highlighted range, comment thread appears in a side panel. Less visual clutter; better on mobile.
Google Docs combines: highlighted range in the doc + thread card in the margin on desktop; sidebar on narrow viewports.
{threads.map(thread => {
const range = resolveAnchor(thread.anchor);
if (!range) return <Orphan thread={thread} />;
const top = computeTopForRange(range, takenSlots);
takenSlots.push({ top, height: cardHeight });
return <ThreadCard thread={thread} style={{ position: "absolute", top, right: 0 }} />;
})}
The “stack to avoid overlap” math is the marginalia subtlety. Sort threads by anchor Y, then place each at max(naturalTop, prevBottom + gap).
Adding a comment
- User selects text in the doc.
- A floating “+ Comment” button appears near the selection.
- Click → composer opens (margin or popover).
- Type → submit → optimistic insert with a clientId; on ack, replace with server id.
The anchor is captured at submit time, not at button-click time — the user can change the selection while composing.
Mentions
- Trigger
@in the composer → typeahead of mentionable users (filtered by who has doc access). - On submit, the message body includes
<mention userId="u_bob">Bob</mention>(or a JSON node in rich text); the API extracts user IDs for notification. - Mentioned users get an in-app + email/push notification with deep link to the thread.
The mention typeahead is itself a typeahead component (see 01_typeahead_autocomplete.md).
Resolving and reopening
- “Resolve” hides the thread from the default view; an “X resolved comments” pill shows the count and toggles visibility.
- Reopening restores it.
- Resolves are events too — they appear in thread history.
Real-time
- WS push: new thread, new message, resolve/reopen, delete.
- Patch the cache surgically (
setQueryData) on each event; refetch as a fallback after extended disconnect. - Show “Bob is typing in this thread” via the same awareness mechanism as collaborative editing, but scoped per thread.
Permissions
- View vs comment vs resolve vs delete — four roles.
- Server is the authority — client renders affordances per the user’s permissions but server re-validates every write.
- Mentions cannot send to users without view access (server filters).
Notifications
- In-app badge on unread mentions and thread updates.
- Email/push when the user is mentioned, when their thread is resolved, when someone replies in a thread they participate in.
- Mute thread — opt out of future notifications for that thread.
Failure modes
- Anchor lost (text deleted from underneath) → orphan; show in a “orphan comments” group at the top of the sidebar with the original quoted text.
- Concurrent edits to the same anchor range → if using relative positions, anchor follows the user who didn’t delete; if absolute, anchor drifts. Mitigation: convert to structural + relative on save.
- Permission revoked mid-session — WS push surfaces; UI disables further comment actions and reloads from server.
- Spam / abuse — server-side rate limit per user; report/delete affordances.
- Mention typo — typeahead must match by name + email; show recently-mentioned at top.
Scale considerations
- Doc with 10K comments — don’t render them all. Virtualize the sidebar; only attach DOM nodes for comments in the viewport. Only resolve anchors for visible comments.
- Long threads (1K replies) — paginate within thread; show last N + “see all replies.”
- Large doc + many comments — anchor resolution per render is expensive; cache
resolveAnchorresults, invalidate on doc edits.
What a senior is expected to say
- “The interesting part is anchor stability under document edits. Absolute char indices break the moment anyone edits before the anchor. I’d use Yjs
Y.RelativePositionfor collab docs, or structural anchors ({blockId, offset}) with fuzzy re-anchoring for non-CRDT docs.” - “Orphan handling is a real UX requirement — when the anchored text is deleted, the comment doesn’t disappear; it moves to an orphan group with the quoted original.”
- “Marginalia stacking: sort by anchor Y, place each card at
max(natural, prevBottom + gap)so they don’t overlap.” - “Mentions trigger notifications on the server; the client just renders typeahead and includes mention markup in the body. Server validates the mentioned user has access.”
- “Permissions are server-enforced; the client renders affordances based on the user’s role but never trusts client state.”
Cross-references
- Collaborative editor (anchor types in CRDT context): 05_collaborative_editor.md
- Typeahead for mentions: 01_typeahead_autocomplete.md
- Notification center: 08_notification_center.md
- Real-time transport: ../11_apis_data_fetching/07_websockets.md
Further reading
- ProseMirror — comments / decorations: https://prosemirror.net/docs/guide/#view.decorations
- Yjs relative positions: https://docs.yjs.dev/api/relative-positions
- Notion / Linear / Figma engineering blogs on comment systems