Frontend / Frontend system design / 06_document_comments.md

Design: Document Comments (Google-Docs Style)

Updated 5 interview angles 7 min read source
On this page16
  1. Requirements to clarify
  2. The anchor problem
  3. API contract
  4. Client data model
  5. Rendering: marginalia vs popovers
  6. Adding a comment
  7. Mentions
  8. Resolving and reopening
  9. Real-time
  10. Permissions
  11. Notifications
  12. Failure modes
  13. Scale considerations
  14. Related
  15. Further reading
  16. Interview angle

Design: Document Comments (Google-Docs Style)

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 Design: Collaborative Editor (CRDT / OT))? 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 Edits, deletion
Absolute char index (start, end) breaks on edits; points to wrong text
Structural anchor { blockId, offsetInBlock } or { paragraphId, range } survives if block stable; orphaned if deleted
Relative position (CRDT) anchored to a text run by the CRDT survives concurrent edits; orphaned on deletion

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

text
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 | null that 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.

tsx
{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

  1. User selects text in the doc.
  2. A floating “+ Comment” button appears near the selection.
  3. Click → composer opens (margin or popover).
  4. 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 Design: Typeahead / Autocomplete).

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 resolveAnchor results, invalidate on doc edits.

Further reading

Interview angle 5

  • “What is actually hard about document comments?” - anchor stability. Storing an absolute character index breaks the moment anyone edits earlier in the document, and every comment silently points at the wrong text. That is the problem the design is really about.
    • Follow-up: “So what do you anchor to?” - in a collaborative document, Y.RelativePosition from the CRDT, which survives concurrent edits by construction. Without a CRDT, a structural anchor of block id plus offset, with fuzzy re-anchoring against the quoted text when the offset no longer matches.
  • “The commented text gets deleted. What happens to the comment?” - it does not vanish. It moves to an orphaned group showing the original quote, because a deleted comment thread is lost work and users assume a bug. Handling orphans is a requirement, not an edge case.
  • “How do you lay out marginal comments without overlap?” - sort by anchor position and place each card at the greater of its natural position and the previous card’s bottom plus a gap. It is a simple pass and it is what stops three comments on adjacent lines from stacking on top of each other.
  • “How do mentions work?” - the client renders the typeahead and includes mention markup in the body; the server resolves it, checks the mentioned user actually has access to the document, and sends the notification. Notifying from the client would let anyone mention anyone.
  • “Where are permissions enforced?” - on the server, always. The client renders affordances based on the user’s role so the UI makes sense, and never treats that as the check. Hiding a delete button is presentation; rejecting the delete is security.