frontend / frontend system design / 06_document_comments.md

Design: Document Comments (Google-Docs Style)

6 min read source

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 | 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.

{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 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 resolveAnchor results, 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.RelativePosition for 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

Further reading