Frontend System Design: Google Docs (Collaborative Document Editor)
-
Frontend System Design: Google Docs (Collaborative Document Editor)
- 1. Concept, Idea, Product Overview
- 1.1 Product Description
- 1.2 Key User Personas
- 1.3 Core User Flows (High Level)
- 2. Requirements
- 2.1 Functional Requirements
- 2.2 Non Functional Requirements
- 3. Scope Clarification (Interview Scoping)
- 3.1 In Scope
- 3.2 Out of Scope
- 3.3 Assumptions
- 4. High Level Frontend Architecture
- 4.1 Overall Approach
- 4.2 Major Architectural Layers
- 4.3 External Integrations
- 5. Component Design and Modularization
- 5.1 Component Hierarchy
- 5.2 Rich Text Editor Engine (Deep Dive)
- 5.3 Reusability Strategy
- 5.4 Module Organization
- 6. High Level Data Flow Explanation
- 6.1 Initial Load Flow
- 6.2 User Interaction Flow
- 6.3 Error and Retry Flow
- 7. Data Modelling (Frontend Perspective)
- 7.1 Core Data Entities
- 7.2 Data Shape
- 7.3 Entity Relationships
- 7.4 UI Specific Data Models
- 8. State Management Strategy
- 8.1 State Classification
- 8.2 State Ownership
- 8.3 Persistence Strategy
- 9. Real Time Collaboration Deep Dive
- 9.1 The Fundamental Problem of Concurrent Editing
- 9.2 Operational Transformation (OT)
- 9.3 CRDTs (Conflict Free Replicated Data Types)
- 9.4 OT vs CRDT Comparison
- 9.5 Cursor and Selection Presence
- 9.6 WebSocket Transport Layer
- 9.7 Offline Editing and Reconnection
- 10. Undo Redo in Collaborative Context
- 11. High Level API Design (Frontend POV)
- 11.1 Required APIs
- 11.2 Request and Response Structure
- 11.3 Error Handling and Status Codes
- 12. Caching Strategy
- 13. CDN and Asset Optimization
- 14. Rendering Strategy
- 15. Cross Cutting Non Functional Concerns
- 15.1 Security
- 15.2 Accessibility
- 15.3 Performance Optimization
- 15.4 Observability and Reliability
- 16. Edge Cases and Tradeoffs
- 17. Summary and Future Improvements
1. Concept, Idea, Product Overview
1.1 Product Description
- Google Docs is a web-based collaborative document editor that allows multiple users to create, edit, and format rich-text documents simultaneously in real time.
- It is the defining example of real-time collaboration on the web — every keystroke from every user is reflected in all other users' views within milliseconds.
- Target users: anyone who writes — students, professionals, writers, teams, enterprises. Over 1 billion users.
- Primary use case: authoring and co-editing rich-text documents (reports, articles, meeting notes, proposals) with real-time multi-user collaboration, commenting, and version history.
1.2 Key User Personas
- Solo Writer: Creates and edits documents alone. Expects fast, responsive typing, rich formatting (headings, lists, tables, images), and auto-save. May work across devices.
- Collaborative Team Member: Edits documents simultaneously with 2-20 team members. Expects real-time cursor visibility, conflict-free concurrent editing, commenting/suggesting workflow, and version history.
- Reviewer / Commenter: Opens shared documents in "Suggesting" or "Viewing" mode. Reads content, leaves comments, suggests edits (tracked changes), and resolves comment threads.
1.3 Core User Flows (High Level)
-
Editing a Document (Primary Flow):
- User opens a document URL → document loads with the latest content.
- User types text → characters appear instantly at the cursor position.
- Every edit is auto-saved to the server (no save button).
- If other users are present, their cursors are visible in real time with names and colors.
- User applies formatting (bold, heading, list) → formatting is applied immediately.
- All changes from all users are merged in real time without conflicts.
-
Commenting and Suggesting (Secondary Flow):
- User selects text → clicks "Add comment" or switches to "Suggesting" mode.
- Comment appears in the right margin, linked to the highlighted text.
- Other users see the comment/suggestion in real time and can reply or resolve it.
-
Version History (Secondary Flow):
- User opens "Version history" panel.
- Sees a timeline of all changes grouped by author and time.
- Can preview any past version and restore it.
2. Requirements
2.1 Functional Requirements
- Rich Text Editing:
- Type, delete, copy, paste, cut text with full Unicode support.
- Inline formatting: bold, italic, underline, strikethrough, code, links, text color, highlight.
- Block formatting: headings (H1–H6), paragraphs, ordered/unordered lists, blockquotes, code blocks.
- Embedded content: images, tables, horizontal rules, page breaks.
- Real Time Collaboration:
- Multiple users editing the same document simultaneously.
- Each user sees all others' changes in real time (sub-second latency).
- Cursor/selection presence: see where each collaborator's cursor is and what they've selected, with a color-coded name label.
- Conflict-free concurrent edits: two users typing at the same position must not lose either's work.
- Comments and Suggestions:
- Add comments on selected text ranges.
- Threaded comment replies.
- "Suggesting" mode: edits appear as tracked changes that can be accepted or rejected.
- Auto Save and Version History:
- All changes are automatically saved — no save button.
- Full version history with timestamps and author attribution.
- Ability to name versions and restore any previous state.
- Sharing and Permissions:
- Share via link with permission levels: Viewer, Commenter, Editor.
- Real-time permission enforcement (viewer cannot edit).
- Export and Import:
- Export as PDF, DOCX, plain text, HTML.
- Import from DOCX, plain text, HTML.
2.2 Non Functional Requirements
- Performance: Typing must feel instantaneous (< 16ms input-to-screen latency for local edits). Document load < 2s for a 50-page document. Support documents with 100+ pages without sluggish scrolling.
- Scalability: Support documents with 100+ pages, 50+ simultaneous editors, and 10,000+ revision history entries. Handle paste operations of 10,000+ characters seamlessly.
- Availability: Offline editing support with sync on reconnect. Graceful degradation when the collaboration server is down — user can continue editing locally.
- Security: Access control enforced at API level (viewer cannot send edit operations). All data in transit is TLS encrypted. Content is not stored in the frontend beyond the active session.
- Accessibility: Full keyboard navigation. Screen reader support for document content (semantic DOM). WCAG AA contrast. Focus management in toolbars and dialogs.
- Device Support: Desktop web (primary), tablet web, mobile web (basic editing). Chrome, Firefox, Safari, Edge.
- i18n: Full RTL support (Arabic, Hebrew) including bidirectional text within documents. IME (Input Method Editor) support for CJK languages. Localized UI in 100+ languages.
3. Scope Clarification (Interview Scoping)
3.1 In Scope
- Rich text editor architecture (document model, rendering, input handling).
- Real-time collaboration deep dive (Operational Transformation, CRDTs, conflict resolution).
- Cursor and selection presence across collaborators.
- Auto-save and sync pipeline.
- Document data model and state management.
- Performance optimization for large documents.
- Undo/redo in a multi-user context.
3.2 Out of Scope
- Backend database and storage (we assume APIs exist).
- Detailed permission system / sharing dialog.
- Rich inline features (smart chips, @mentions autocomplete, spell check).
- Add-ons and extensions marketplace.
- Native mobile app.
- Print/PDF rendering pipeline.
3.3 Assumptions
- User is authenticated; auth token is available.
- A WebSocket-based collaboration server exists (we design the frontend interaction).
- Server provides an OT/CRDT-compatible API for document operations.
- Documents are fetched as structured JSON (not raw HTML).
- Media (images) are uploaded to a separate service and referenced by URL.
4. High Level Frontend Architecture
4.1 Overall Approach
- SPA (Single Page Application) with client-side routing.
- CSR (client-side rendering) only. A minimal static HTML app shell (
index.html+ JS/CSS bundles) is served from the CDN; everything — editor chrome, document metadata, and document content — is rendered on the client. We deliberately skip SSR because: the document content changes constantly (any collaborator edit invalidates server-rendered content instantly), it's auth-gated so there's no SEO benefit, the canvas rendering engine owns painting on the client (there is no server-paintable HTML to hydrate), and the editor needs the structured model JSON client-side anyway to type/undo/apply OT — once that's fetched, painting the visible viewport is single-digit milliseconds. - Hybrid canvas rendering engine (the approach real Google Docs uses since 2021). The document content area is painted onto a
<canvas>rather than built as DOM nodes. Around that canvas we keep a parallel DOM layer the browser must still drive: a hidden input proxy for keystrokes/IME, an offscreen mirrored DOM for accessibility, and native selection/clipboard integration.contentEditableis NOT used for the visible content — only a hidden input element captures input. The chrome (menus, toolbar, sidebars, dialogs) is ordinary DOM. - Monolith frontend — the editor is highly integrated; micro-frontends add unnecessary complexity for a deeply coupled editing experience.
4.2 Major Architectural Layers
┌──────────────────────────────────────────────────────────────┐
│ UI Layer │
│ ┌───────────────────────┐ ┌─────────────────────────────┐ │
│ │ Toolbar / Menu Bar │ │ Editor Surface │ │
│ │ (formatting, actions) │ │ ┌──────────────────────┐ │ │
│ ├───────────────────────┤ │ │ <canvas> content │ │ │
│ │ Comment Sidebar │ │ │ (PAINTED from model) │ │ │
│ │ (threads, replies) │ │ ├──────────────────────┤ │ │
│ ├───────────────────────┤ │ │ Cursor Overlays │ │ │
│ │ Version History Panel │ │ │ (remote cursors) │ │ │
│ │ (timeline, preview) │ │ ├──────────────────────┤ │ │
│ ├───────────────────────┤ │ │ Selection Highlights │ │ │
│ │ Permissions Banner │ │ │ (remote selections) │ │ │
│ │ (viewer / editor) │ │ └──────────────────────┘ │ │
│ └───────────────────────┘ └─────────────────────────────┘ │
├──────────────────────────────────────────────────────────────┤
│ Document Model Layer │
│ (Tree / flat structure of nodes: paragraphs, text runs, │
│ inline marks, block types, embedded objects) │
├──────────────────────────────────────────────────────────────┤
│ Editor Core (Command / Transaction Layer) │
│ (Operations: insert, delete, format, split, merge. │
│ Selection model, Schema validation, Input handling) │
├──────────────────────────────────────────────────────────────┤
│ Collaboration Layer │
│ (OT / CRDT engine, WebSocket transport, │
│ Presence manager, Conflict resolution) │
├──────────────────────────────────────────────────────────────┤
│ Persistence Layer │
│ (Auto-save manager, Version history client, │
│ Offline buffer, Export/Import) │
├──────────────────────────────────────────────────────────────┤
│ Shared / Utility Layer │
│ (Keyboard manager, Clipboard handler, IME handler, │
│ Selection utilities, Analytics tracker) │
└──────────────────────────────────────────────────────────────┘
Rendering note (hybrid canvas): the Editor Surface box is a
<canvas>painted from the document model — not DOM nodes. Between the UI Layer and the Document Model Layer sits a Rendering Layer (Canvas): a layout engine (line breaking, pagination, BiDi), a text shaper, a canvas painter that repaints only dirty regions, and a geometry map (model position ↔ pixel coordinates) used for hit-testing clicks and positioning the caret. Alongside the canvas, a parallel DOM provides the hidden input proxy (keystrokes/IME), native selection/clipboard, and an offscreen mirror DOM for accessibility. All chrome (menus, toolbar, sidebars) is ordinary DOM.
4.3 External Integrations
- Collaboration Server: WebSocket-based server that receives operations from all clients, transforms them (OT), and broadcasts the transformed operations to all other clients.
- Document Storage API: REST API for loading/saving document snapshots, version history, and metadata.
- Media Upload Service: Separate endpoint for image/file uploads; returns a URL for embedding.
- Comment Service: REST + real-time API for comments and suggestion threads.
- Analytics SDK: Track editing session duration, collaboration metrics, feature usage.
- Spell Check / Grammar Service: Server-side NLP service called asynchronously with document text.
5. Component Design and Modularization
5.1 Component Hierarchy
DocumentPage
├── MenuBar
│ ├── FileMenu (new, open, import, export, share)
│ ├── EditMenu (undo, redo, cut, copy, paste, find/replace)
│ ├── ViewMenu (zoom, page break, ruler)
│ └── InsertMenu (image, table, link, heading, page break)
│
├── Toolbar
│ ├── UndoRedoButtons
│ ├── FontSelector
│ ├── FontSizeSelector
│ ├── InlineFormatButtons (bold, italic, underline, strikethrough, code)
│ ├── TextColorPicker
│ ├── HighlightColorPicker
│ ├── AlignmentButtons (left, center, right, justify)
│ ├── ListButtons (ordered, unordered, checklist)
│ ├── IndentButtons (indent, outdent)
│ └── InsertButtons (link, image, table, comment)
│
├── EditorSurface (main editing area)
│ ├── CanvasContent (<canvas> — the document is PAINTED here from the model,
│ │ not built as DOM nodes; simulates paper pages, handles pagination)
│ │ └── (logical, painted per frame) BlockNode[] → ParagraphBlock / HeadingBlock /
│ │ ListBlock / CodeBlock / BlockquoteBlock / TableBlock / ImageBlock
│ │ (these are MODEL nodes the painter draws — there is no DOM per block)
│ ├── CursorOverlay (local caret + remote cursors with name labels)
│ ├── SelectionOverlay (colored highlights for local + remote selections)
│ ├── HiddenInputProxy (invisible focusable element — captures keystrokes,
│ │ beforeinput, paste, and IME composition; visible caret is painted, not native)
│ ├── AccessibilityMirror (offscreen semantic DOM: <h1>/<p>/<ul>/<table>…,
│ │ kept in sync with the model so screen readers can read/navigate)
│ └── FloatingToolbar (appears on text selection — quick format options)
│
├── CommentSidebar
│ └── CommentThread[]
│ ├── CommentBody
│ ├── CommentReplies
│ └── ResolveButton
│
├── VersionHistoryPanel (slide-in from right)
│ ├── VersionTimeline
│ └── VersionPreview
│
└── CollaboratorAvatars (top-right — shows who's online)
5.2 Rich Text Editor Engine (Deep Dive)
Building a collaborative rich text editor is one of the most complex frontend engineering challenges. The editor must:
- Capture user input reliably across all browsers, IME systems, and operating systems.
- Maintain a structured document model that is independent of the DOM.
- Paint the document model to a
<canvas>efficiently, while keeping a parallel offscreen DOM for accessibility. - Support concurrent editing from multiple users without conflicts.
- Handle undo/redo correctly in a multi-user context.
5.2.1 Why ContentEditable vs Custom Rendering
Every rich text editor on the web must decide how to capture user input and render content. There are three fundamental approaches:
| Approach | How it works | Used by | Pros | Cons |
|---|---|---|---|---|
Full contentEditable |
Set contentEditable="true" on a div. Let the browser handle all input, rendering, selection, and formatting. |
Simple WYSIWYG editors (Quill v1, Medium's first editor) | Zero effort for basic editing; browser handles IME, spell check, autocomplete natively | Wildly inconsistent across browsers; DOM is the model (fragile); very hard to add custom behavior; nightmare for collaboration |
| ContentEditable for input, custom model for data | Use contentEditable to capture keystrokes and IME input. But maintain a separate structured data model. On input, translate the DOM mutation into a model operation, then re-render the model back to DOM. |
Slate.js, ProseMirror, TipTap, pre-2021 Google Docs | Input capture is reliable (leverages browser's IME/spell check); model is clean and structured; collaboration-friendly | Must keep DOM and model in sync; complex input pipeline; occasional browser quirks in contentEditable |
| Custom input (Canvas + parallel DOM) | Paint the document on a <canvas> element. Capture input via a hidden editable/<textarea> element and drive selection, clipboard, and the accessibility tree through a parallel (often off-screen) DOM. Bypass contentEditable for rendering only. |
Google Docs (2021+ hybrid canvas renderer), Figma (for text editing) | Full control over rendering and layout; pixel-perfect consistency; no browser DOM rendering quirks | Must implement ALL text rendering (line breaking, BiDi, ligatures, caret blinking, selection rectangles) AND maintain a mirror DOM for a11y/IME/clipboard; massive engineering effort |
Strategy for this design: hybrid canvas rendering + custom document model + parallel DOM for input/a11y.
This is the architecture real Google Docs uses today (since 2021), and the one we adopt here. The document content is painted onto a <canvas> from the structured model, giving us pixel-perfect, browser-consistent rendering and full control over pagination and layout. Everything the browser must still natively drive is handled by a parallel DOM layer that sits with the canvas:
- Input capture — a hidden, focusable input element (a tiny
contentEditable/<textarea>proxy) receives keystrokes,beforeinput, and paste events. It is visually invisible; the visible caret is painted on the canvas. - IME / composition — CJK and dead-key composition run through that same hidden input element and the browser's composition APIs, then commit a single operation to the model.
- Selection & clipboard — copy/cut/paste and OS-level selection integrate through the DOM; the visible selection rectangles are painted on the canvas from the layout geometry.
- Accessibility — an offscreen, semantically-structured mirror DOM (
<h1>,<p>,<ul>,<table>…) is kept in sync with the model so screen readers can read and navigate the document (a bare<canvas>is opaque to assistive tech).
So the one-line framing for an interview: "Canvas paints the content for consistency and performance; a parallel DOM layer provides input, IME, selection, clipboard, and accessibility." This directly answers the classic follow-up — "if it's all canvas, how do screen readers work?" — with the offscreen mirror DOM.
Cost of this choice (be honest in an interview): we must implement our own layout engine (line breaking, pagination, BiDi, ligatures), our own caret/selection painting, and keep the mirror DOM in sync — a large engineering investment. The alternative (contentEditable for input + DOM rendering, used by ProseMirror/Slate/TipTap and pre-2021 Google Docs) is far less work and gets native IME/spell-check/selection "for free," at the cost of browser rendering inconsistencies. We choose canvas here to match modern Google Docs and to control large-document performance; §17 notes the DOM approach as a lighter-weight fallback.
5.2.2 Document Model (Structured Data, Not HTML)
The editor does NOT use HTML as its data model. Instead, it maintains a structured tree of typed nodes that represents the document content independently of how it's displayed.
Why not use the DOM as the model?
| Aspect | DOM as model | Custom structured model |
|---|---|---|
| Data shape | Inconsistent HTML — the DOM a browser produces for the same user action varies across browsers and editing scenarios (stray wrapper <span>s, <br>s, legacy <font>/<b> nodes) |
Clean, typed, predictable JSON tree |
| Serialization | Must parse/sanitize HTML |
JSON.stringify() — trivial |
| Collaboration | How do you diff arbitrary HTML? Very hard | Operations on typed nodes are well-defined |
| Undo/redo | Browser's built-in undo is unreliable across browsers | Custom command stack with full control |
| Validation | Can the DOM contain a heading inside a list item? No easy way to enforce | Schema defines allowed structures; invalid changes are rejected |
| Cross-platform | Different browsers produce different HTML for the same user action | Model is browser-agnostic |
Document Tree Structure
Document
└── Block[]
├── Paragraph
│ └── TextRun[]
│ ├── { text: "Hello ", marks: [] }
│ ├── { text: "world", marks: ["bold"] }
│ └── { text: "!", marks: [] }
│
├── Heading (level: 2)
│ └── TextRun[]
│ └── { text: "Introduction", marks: [] }
│
├── BulletList
│ └── ListItem[]
│ ├── ListItem
│ │ └── Paragraph → TextRun[]
│ └── ListItem
│ └── Paragraph → TextRun[]
│
├── CodeBlock
│ └── { text: "const x = 1;", language: "javascript" }
│
├── Table
│ └── TableRow[]
│ └── TableCell[]
│ └── Block[] (paragraphs inside cells)
│
└── Image
└── { src: "...", alt: "...", width: 400, height: 300 }
JSON Representation
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hello ", "marks": [] },
{ "type": "text", "text": "world", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": "!", "marks": [] }
]
},
{
"type": "heading",
"attrs": { "level": 2 },
"content": [
{ "type": "text", "text": "Introduction", "marks": [] }
]
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "First item", "marks": [] }
]
}
]
}
]
}
]
}
This model is:
- Framework-agnostic — just data; can be rendered by React, vanilla JS, or even Canvas.
- Serializable —
JSON.stringify()for saving;JSON.parse()for loading. - Diffable — operations transform this model, and diffs can be computed for collaboration.
- Validatable — a schema defines what node types exist and where they can appear.
5.2.3 Selection and Cursor Model
The browser's native Selection API is DOM-based (anchored to DOM nodes + offsets) — but our content is painted on a <canvas> and has no per-character DOM to anchor to. So the editor uses a model-based selection: positions that reference the document model, independent of any rendering. (The hidden input proxy and offscreen a11y mirror have their own DOM selections, but those are derived from the model selection, not the source of truth.)
Model Position
A position in the document model is a numerical offset counting from the start of the document. Every "step" into or out of a node counts as one position:
Content: "Hello world"
Position: 0 1 2 3 4 5 6 7 8 9 10 11
If the model is two paragraph blocks: Paragraph["Hello"] Paragraph["World"]
Model positions (counting steps into/out of MODEL nodes — no DOM involved):
0: before Paragraph #1
1: before 'H'
2: before 'e'
3: before 'l'
4: before 'l'
5: before 'o'
6: after 'o' / after Paragraph #1
7: before Paragraph #2
8: before 'W'
...
A selection is: { anchor: 2, head: 5 } → selects "ello"
A cursor is: { anchor: 5, head: 5 } → cursor after "Hello"
This model-based selection is:
- Independent of the DOM — if the canvas repaints, the model position is still valid.
- Shareable — we can send
{ anchor: 5, head: 5 }to other collaborators to show our cursor. - Transformable — when a remote user inserts text before position 5, we can adjust our position to 5 + insertLength.
With canvas rendering, there is no native browser caret in the content. We convert a model position to pixel coordinates using the layout geometry map, then paint the caret (a blinking vertical bar) on the canvas ourselves. Conversely, a mouse click is hit-tested against the geometry map to find the nearest model position. The hidden input proxy holds focus so the OS keeps sending key events, but its native caret is not shown.
type EditorSelection = {
anchor: number; // where the selection started (user pressed mouse down)
head: number; // where the selection ended (user released mouse)
};
// Cursor is a collapsed selection
type Cursor = EditorSelection; // where anchor === head
-
anchoris the fixed end (where you pressed down);headis the moving end (where the caret currently is). Dragging left-to-right vs right-to-left just swaps which is larger — the selected range is always between them. - A collapsed selection (
anchor === head) has zero width: it is simply the blinking cursor, with no text selected. Every cursor is just a selection where both ends coincide, so the editor only needs one concept to represent both. - Because a selection is two integers, the whole editor speaks one language: input, formatting, comments, and presence all describe ranges as
{ from, to }model positions.
5.2.4 Input Handling Pipeline
The input pipeline converts raw browser events into document model operations. This is the most challenging part of editor development because of IME (Input Method Editor), autocorrect, speech-to-text, and browser inconsistencies.
Browser event on the HIDDEN INPUT PROXY
(keydown, beforeinput, input, compositionstart/end, paste, drop)
↓
Input Handler (intercepts and classifies)
↓
┌─────────────────────────────────────────────────────────┐
│ Classification: │
│ - Regular character input → create InsertText op │
│ - Backspace/Delete → create DeleteText op │
│ - Enter → create SplitBlock op │
│ - Tab in list → create IndentListItem op │
│ - Ctrl+B → create ToggleMark("bold") op │
│ - Paste → parse clipboard → create InsertSlice op │
│ - IME composition → buffer until compositionend │
│ - Drop → parse drag data → create InsertSlice op │
└─────────────────────────────────────────────────────────┘
↓
Create Transaction (one or more operations)
↓
Apply Transaction to Document Model
↓
Emit to Collaboration Layer (send operation to server)
↓
Re-layout affected lines → REPAINT the dirty canvas region (view update)
↓
Sync the offscreen accessibility mirror DOM + update the painted caret position
Where does input come from with a canvas? A
<canvas>can receive focus and keyboard events — but it is not a native text-editing control. It provides none of the browser-managed editing machinery: no text insertion, no IME composition, no selection model, no clipboard behavior, no accessibility tree. So we place a hidden, focusable input proxy (a tinycontentEditableor<textarea>) over the canvas to get all of that. It holds focus, receives all keystrokes /beforeinput/ paste / IME events, and we translate those into model operations — then repaint. The real caret is painted on the canvas; the proxy's own caret is hidden.
Why beforeinput Event is Critical
Modern editors lean on the beforeinput event (InputEvent Level 2) as the primary signal — but not the only one. Browser support and fidelity vary (Safari has historically had beforeinput quirks, Firefox support lagged for years), so production editors combine several events: beforeinput (intercept intent), compositionstart/update/end (IME), keydown (shortcuts and keys beforeinput doesn't cleanly expose), input (a fallback to reconcile DOM the browser mutated anyway), and selectionchange (track caret/selection). The mental model is "beforeinput-first, with composition/keydown/input/selectionchange as complements," not "beforeinput alone."
| Event | What it tells you | Why it matters |
|---|---|---|
keydown |
Which physical key was pressed | Doesn't tell you what character will be produced (IME, dead keys, compose) |
input |
The DOM was already mutated | Too late — the DOM changed before you could intercept |
beforeinput |
What the browser intends to do AND you can prevent it | You can intercept, create your own model operation, and cancel the default mutation |
What does
preventDefault()actually prevent? It stops the browser from mutating the hidden input proxy's DOM — not the visible document (the visible document is a canvas the browser can't touch anyway). The mental model: the browser does mutate the hidden proxy on input, but we cancel that and instead apply a clean operation to our model, then repaint. So the invariant is "the browser never mutates the editor's visible document," not "the browser never runs."
// `inputProxy` is the hidden, focusable element overlaid on the canvas.
inputProxy.addEventListener('beforeinput', (event: InputEvent) => {
// Prevent the proxy from mutating its own DOM directly
event.preventDefault();
switch (event.inputType) {
case 'insertText':
// User typed a character
applyOperation(insertText(selection, event.data!));
break;
case 'insertParagraph':
// User pressed Enter
applyOperation(splitBlock(selection));
break;
case 'deleteContentBackward':
// User pressed Backspace
applyOperation(deleteBackward(selection));
break;
case 'deleteContentForward':
// User pressed Delete
applyOperation(deleteForward(selection));
break;
case 'insertFromPaste':
// User pasted content
const html = event.dataTransfer?.getData('text/html');
const text = event.dataTransfer?.getData('text/plain');
applyOperation(insertSlice(selection, parseClipboard(html, text)));
break;
case 'formatBold':
applyOperation(toggleMark('bold', selection));
break;
// ... other inputTypes
}
});
IME (Input Method Editor) Handling
For CJK (Chinese, Japanese, Korean) languages, users type in a composition mode where multiple keystrokes produce a single character. (Those Latin letters aren't English — "ni" is romaji/pinyin, the phonetic spelling typed on a normal QWERTY keyboard, which the IME converts into the target character: Japanese に, Chinese 你, etc. CJK users don't have per-character keys, so they type the pronunciation and the IME assembles/picks the character.)
Who starts composition — and how the editor detects it. The editor never starts composition; the OS + IME + browser do. When an IME is active at the OS level and the user types into a focusable editable element, the OS routes keystrokes into the IME engine, which tells the browser to fire compositionstart. The editor is purely reactive: it listens for the composition events and buffers.
⚠️ Why the input proxy must be truly editable: browsers only fire
compositionstart/update/endon an editable element (contentEditableor<textarea>). A plain<div>or a<canvas>receives no composition events. This is a core reason the design uses a real hidden editable proxy — it's the only way to receive IME input at all.
How you decide "this is composition" — the isComposing flag. You don't guess by language. Two unambiguous signals:
- The
compositionstart/compositionendpair — set a boolean flag between them. - Every event fired mid-composition also carries
event.isComposing === true(onbeforeinput,keydown,input). Soif (event.isComposing) return;is the decision — "stay out of the way until composition ends." (Historically, akeydownwithkeyCode === 229was the "IME is handling this key" signal, used beforeisComposingexisted.)
The exact event sequence for romaji ni → に:
compositionstart ← IME began; set isComposing = true
↓
keydown (keyCode 229 = "IME handling")
beforeinput (inputType: "insertCompositionText", isComposing: true) ← SKIP
compositionupdate (data: "n") ← preview; browser paints "n" in the proxy
↓
keydown (229)
beforeinput (insertCompositionText, isComposing: true) ← SKIP
compositionupdate (data: "に") ← preview now shows "に"
↓
compositionend (data: "に") ← FINAL; isComposing = false → commit "に" ONCE
Key rule: everything between start and end is a preview the browser renders inside the hidden proxy — we do NOT touch the document model. Only on compositionend do we apply the final composed text as a single operation and repaint the canvas.
Where does the preview show with a canvas? During composition, the IME candidate popup and the underlined composing text are rendered natively by the browser inside the hidden input proxy, not on the canvas. Most editors briefly position the proxy at the caret (via the geometry map) and let it be visible during composition so the browser handles the IME UI for free — then hide it and paint the committed text on the canvas at
compositionend.
let isComposing = false;
inputProxy.addEventListener('compositionstart', () => {
isComposing = true;
// Let the browser handle composition rendering natively (in the proxy)
});
inputProxy.addEventListener('compositionend', (event) => {
isComposing = false;
// Now apply the final composed text as a single operation, then repaint
applyOperation(insertText(selection, event.data));
});
// In beforeinput handler, skip input during composition
inputProxy.addEventListener('beforeinput', (event) => {
if (event.isComposing || isComposing) return; // the decision: don't touch the model mid-composition
event.preventDefault();
// ... handle normally
});
5.2.5 Rendering Pipeline (Model to Canvas)
After every edit, the document model changes and the <canvas> must be repainted to reflect it. This is NOT a full repaint of the whole document — it's a layout + dirty-region repaint that re-lays-out only the affected lines and repaints only the pixels that changed.
Document Model (after edit)
↓
Layout pass (only the affected block/lines):
- break text into lines (wrapping, BiDi, tabs)
- shape runs into glyphs (font, size, marks)
- compute geometry: x/y/width/height per line, per run, per glyph cluster
- update the geometry map (model position ↔ pixel coordinates)
↓
Paint pass (dirty region only):
- clearRect() the changed region of the canvas
- draw text runs with their marks (bold/italic/color/highlight/link)
- draw block decorations (list bullets, blockquote bar, table grid)
- draw the caret + selection rectangles from the geometry map
↓
Sync the offscreen accessibility mirror DOM for the changed blocks
Because the content is pixels (not DOM), there is no live browser selection inside the content to disturb — repainting a line cannot make the caret "jump," and IME composition (handled in the hidden proxy) is unaffected. Repaints are scheduled on requestAnimationFrame so multiple model changes in one frame coalesce into a single paint.
Why Canvas Instead of DOM (or React) for the Content?
| Approach | How it works | Assessment |
|---|---|---|
| React-rendered content | Each paragraph / text run is a React component | Rejected. The core problem is DOM ownership: the browser (editing/IME/selection), the OS, and React's reconciler all fight over the same editable DOM subtree → caret loss, IME corruption, reconciler clobbering native mutations. Typing latency is only one symptom. |
| Custom DOM patching | Editor kernel patches text nodes directly (textNode.data = ...) |
Viable, lighter-weight (ProseMirror / Slate / pre-2021 Google Docs). Native IME/selection/spell-check for free, but you inherit browser rendering inconsistencies and still fight contentEditable quirks. Good fallback (see §17). |
| Canvas painting | Paint glyphs onto <canvas> from the model; parallel DOM for input/a11y |
Chosen (modern Google Docs). Pixel-perfect, browser-consistent rendering; full control over line breaking, pagination, and large-document performance. Cost: build your own layout/paint engine and keep a mirror DOM for accessibility. |
Modern Google Docs paints content on canvas; ProseMirror / Slate patch the DOM directly. Neither uses a framework's virtual DOM for the editable content.
The paint routine for a paragraph (conceptual):
// ctx is the 2D canvas context; layout is the pre-computed geometry for this block.
function paintParagraph(ctx: CanvasRenderingContext2D, block: ParagraphNode, layout: BlockLayout) {
// Repaint only this block's rectangle
ctx.clearRect(layout.x, layout.y, layout.width, layout.height);
for (const line of layout.lines) {
for (const runBox of line.runs) {
const run = runBox.run;
// Build the font string from the run's marks
let fontStyle = '';
let fontWeight = 'normal';
let fillStyle = '#202124';
for (const mark of run.marks) {
if (mark.type === 'bold') fontWeight = 'bold';
if (mark.type === 'italic') fontStyle = 'italic';
if (mark.type === 'textColor') fillStyle = mark.attrs.color;
if (mark.type === 'highlight') {
ctx.fillStyle = mark.attrs.color;
ctx.fillRect(runBox.x, runBox.y, runBox.width, runBox.height); // paint highlight behind
}
}
ctx.font = `${fontStyle} ${fontWeight} ${block.fontSize}px ${block.fontFamily}`;
ctx.fillStyle = fillStyle;
ctx.fillText(run.text, runBox.x, runBox.baselineY);
// Underline for links / underline mark
if (run.marks.some(m => m.type === 'underline' || m.type === 'link')) {
ctx.fillRect(runBox.x, runBox.baselineY + 2, runBox.width, 1);
}
}
}
}
The geometry map is the crux of a canvas editor. Because the content has no DOM, you must maintain a
position ↔ pixelmap produced by the layout pass. It powers three things: (1) hit-testing clicks/drag-selection back to model positions, (2) painting the caret and selection rectangles, and (3) positioning remote collaborators' cursors. It's cached per block (e.g. in aWeakMapkeyed by block) and recomputed incrementally only for blocks whose content or width changed — this is what keeps repaint cheap on a 100+ page document.
The Pipeline in Plain Terms
The document model is the source of truth (the words and their styles); the <canvas> is only a view of it. Rendering is the act of turning the model into pixels, and the whole design is optimized around one goal: make typing feel instant. Five ideas carry the section:
Repaint only what changed, never the whole document. Typing one character marks a single line "dirty." The pipeline erases only that line's rectangle (
clearRect) and repaints only it; the other lines — potentially hundreds of pages — are left untouched. A full-document repaint on every keystroke would be prohibitively slow.Layout comes before paint — measure, then draw. The layout pass decides where everything goes: it breaks text into lines (wrapping, BiDi, tabs), shapes runs into glyphs for the chosen font, and records the x/y/width/height of every line, run, and glyph cluster. Only then does the paint pass actually draw those glyphs and their marks. Layout is the pencil guidelines; paint is the ink.
Pixels can't be fought over. In the DOM approach every character is a live node that the browser, the OS, and the framework's reconciler all contend for — the root cause of caret loss and IME corruption. Canvas content is just pixels, so repainting a line can never dislodge the caret, and IME composition (which lives in the hidden proxy) is never touched.
Batch paints to the frame. Several model changes in one frame (e.g. a fast burst of keystrokes) coalesce into a single paint scheduled on
requestAnimationFrame(~60 fps), rather than painting once per change.The geometry map makes an image editable. Because the content is pixels with nothing to point at, the
position ↔ pixelmap is what lets a click resolve to a model position (hit-testing), lets the caret and selection rectangles be painted at the right place, and lets remote collaborators' cursors be positioned. Without it, a canvas is unusable for editing.
In one sentence: on each edit the engine re-lays-out and repaints only the affected line, draws its glyphs with their marks onto the canvas, keeps a position ↔ pixel map so clicks and carets work, and batches paints to the animation frame — so editing stays instant even on a 100+ page document.
Hit-Testing (Pixel → Model Position)
Hit-testing answers: "the user clicked at pixel (x, y) — what model position is that?" With DOM, the browser does this for free — a click lands on a specific <span> node. With canvas the content is just pixels, so the browser can only report a coordinate, not a character. The editor must translate that coordinate back to a model position itself, using the same geometry map the layout pass produced.
The lookup walks the geometry map from coarse to fine:
Click at (310, 88)
1. Which line's vertical band contains y = 88? → line 4
2. Within line 4, which run's x-range contains x = 310? → run "world"
3. Within that run, which glyph edge is x = 310 nearest? → between 'r' and 'l'
4. Return the model position → position 42
The editor now knows the click means model position 42 and can place the caret, start a drag-selection, extend a selection, or resolve a word boundary from there.
| Interaction | What hit-testing resolves |
|---|---|
| Single click | one pixel → caret position |
| Click-drag | two pixels → selection range (anchor + head) |
| Double-click | pixel → surrounding word boundaries |
| Hover / click a link | pixel → is a link mark present at this position? |
Hit-testing is the pixel → position direction; painting the caret and remote cursors is the reverse position → pixel direction. Both query the same geometry map — which is exactly why §5.2.5 calls it the crux of a canvas editor.
5.2.6 Block and Inline Formatting
Inline Marks (Applied to Text Runs)
Marks are formatting attributes attached to text ranges. A single character can have multiple marks (bold + italic + link).
Text: "Hello bold world"
Marks: [
{ type: "text", text: "Hello ", marks: [] },
{ type: "text", text: "bold", marks: [{ type: "bold" }] },
{ type: "text", text: " world", marks: [] },
]
When the user selects "bold" and presses Ctrl+I (italic):
Before: { text: "bold", marks: [{ type: "bold" }] }
After: { text: "bold", marks: [{ type: "bold" }, { type: "italic" }] }
Block Types (Applied to Structural Nodes)
Block operations change the type of a node in the document tree:
Before: { type: "paragraph", content: [{ text: "My Title" }] }
↓ User applies "Heading 1"
After: { type: "heading", attrs: { level: 1 }, content: [{ text: "My Title" }] }
Formatting as Operations
Every formatting change is an operation in the collaboration sense:
type FormatOperation = {
type: 'addMark' | 'removeMark' | 'setBlockType';
from: number; // start position in document
to: number; // end position
mark?: Mark; // for inline marks
blockType?: string; // for block type changes
attrs?: Record<string, any>;
};
This operation can be:
- Applied locally and rendered immediately.
- Sent to the collaboration server for transformation and broadcast.
- Stored in the undo/redo history.
5.2.7 Embedded Content (Images, Tables, Embeds)
Images
Images are "atom" nodes — they are leaf nodes in the document tree that represent a single object, not a container of text.
type ImageNode = {
type: 'image';
attrs: {
src: string;
alt: string;
width: number;
height: number;
alignment: 'left' | 'center' | 'right' | 'inline';
};
};
Image upload flow:
- User pastes/drops an image.
- Generate a temporary local blob URL → insert an
ImageNodewithsrc = blob:.... - Upload the image to the media service in the background.
- On upload success, replace
srcwith the permanent CDN URL. - If upload fails, show an error overlay on the image with a retry button.
Tables
Tables are complex nested structures:
type TableNode = {
type: 'table';
content: TableRowNode[];
};
type TableRowNode = {
type: 'tableRow';
content: TableCellNode[];
};
type TableCellNode = {
type: 'tableCell';
attrs: {
colspan: number;
rowspan: number;
backgroundColor?: string;
};
content: BlockNode[]; // Each cell contains paragraphs
};
Tables are particularly challenging for collaboration because operations can affect multiple cells (merge cells, add row, resize columns) and must be correctly transformed.
5.3 Reusability Strategy
-
EditorCore: Headless editor engine (document model, operations, schema) — reusable across different UI surfaces (full editor, comment editor, inline text fields). -
FormatButton: Generic toolbar button component that reads the current selection's state and dispatches format operations. -
BlockPainter: Polymorphic painter that delegates to a type-specific paint routine based on the block'stypeproperty (paragraph, heading, list, table, image). -
CollaborationProvider: React context that provides the WebSocket connection and OT/CRDT engine to any component that needs it. -
PresenceCursor: Renders a colored cursor with a name label; reused for each remote collaborator. - Design tokens for editor chrome (toolbar colors, spacing, font stacks).
5.4 Module Organization
src/
├── editor/
│ ├── model/
│ │ ├── documentModel.ts (tree structure, node types)
│ │ ├── schema.ts (allowed node/mark types and relationships)
│ │ ├── selection.ts (model-based selection)
│ │ └── position.ts (position mapping, resolve)
│ ├── operations/
│ │ ├── insertText.ts
│ │ ├── deleteText.ts
│ │ ├── splitBlock.ts
│ │ ├── joinBlocks.ts
│ │ ├── setBlockType.ts
│ │ ├── toggleMark.ts
│ │ ├── insertSlice.ts (paste/drop)
│ │ └── tableOps.ts (add row, merge cell, etc.)
│ ├── render/
│ │ ├── canvasView.ts (canvas setup, rAF repaint loop, dirty-region tracking)
│ │ ├── layoutEngine.ts (line breaking, wrapping, pagination, BiDi)
│ │ ├── textShaper.ts (font metrics, glyph shaping, measure cache)
│ │ ├── blockPainter.ts (paint paragraph, heading, list, table, image)
│ │ ├── markPainter.ts (bold, italic, underline, link, highlight)
│ │ ├── geometryMap.ts (model position ↔ pixel coords; hit-testing)
│ │ ├── caretSelectionPaint.ts (paint local/remote carets + selection rects)
│ │ ├── a11yMirror.ts (offscreen semantic DOM synced from model)
│ │ └── decorations.ts (comment highlights, find/replace highlights)
│ ├── input/
│ │ ├── inputProxy.ts (hidden focusable element over the canvas)
│ │ ├── inputHandler.ts (beforeinput, keydown)
│ │ ├── imeHandler.ts (composition events)
│ │ ├── clipboardHandler.ts (paste, copy, cut)
│ │ └── dropHandler.ts (drag and drop)
│ └── history/
│ ├── undoManager.ts
│ └── historyEntry.ts
├── collaboration/
│ ├── otClient.ts (OT client state machine)
│ ├── otTransform.ts (transform functions)
│ ├── wsTransport.ts (WebSocket connection)
│ ├── presenceManager.ts (cursor/selection sharing)
│ └── offlineBuffer.ts (queue ops when offline)
├── components/
│ ├── DocumentPage.tsx
│ ├── EditorSurface.tsx
│ ├── Toolbar.tsx
│ ├── FormatButton.tsx
│ ├── CommentSidebar.tsx
│ ├── VersionHistoryPanel.tsx
│ ├── CollaboratorAvatars.tsx
│ ├── CursorOverlay.tsx
│ └── ImageUploadOverlay.tsx
├── api/
│ ├── documentApi.ts
│ ├── commentApi.ts
│ └── mediaApi.ts
├── shared/
│ ├── keyboard.ts
│ ├── clipboard.ts
│ ├── debounce.ts
│ └── analytics.ts
└── types/
└── types.ts
6. High Level Data Flow Explanation
6.1 Initial Load Flow
1. User navigates to /doc/:docId
↓
2. CDN serves the static app shell (index.html + JS/CSS bundles) — no server-side rendering
↓
3. App boots on the client → DocumentPage mounts (renders editor chrome immediately)
↓
4. Fetch full document: GET /api/docs/:docId
↓
5. Initialize document model from response JSON
↓
6. Paint document model onto the content canvas (editor surface); build the a11y mirror DOM
↓
7. Establish WebSocket connection for collaboration: wss://collab.example.com/:docId
↓
8. Receive any pending operations from other users since the snapshot was taken
↓
9. Apply pending operations → document is now in sync
↓
10. Editor is ready — user can type
6.2 User Interaction Flow
Typing a character:
User types "a"
↓
1. beforeinput fires on the hidden input proxy (inputType: "insertText", data: "a")
↓
2. event.preventDefault() — prevent the proxy from mutating its own DOM
↓
3. Create operation: insert("a", position=42)
↓
4. Apply operation to document model → model updated
↓
5. Re-layout the affected line → repaint that dirty canvas region → user sees "a" instantly
↓
6. Update model selection (move caret right by 1) → repaint caret; sync a11y mirror
↓
7. Record in undo history
↓
8. Send operation to collaboration server via WebSocket
↓
9. Server transforms + broadcasts to other clients
↓
10. Other clients apply the transformed operation → they repaint and see "a" appear
Applying formatting:
User selects "world" → presses Ctrl+B
↓
1. Create operation: addMark("bold", from=6, to=11)
↓
2. Apply to model → text run splits:
["Hello ", "world" (bold), "!"]
↓
3. Repaint the affected line → "world" is painted with bold glyphs
↓
4. Toolbar "Bold" button updates to show active/pressed state
↓
5. Send operation to server → broadcast to collaborators
6.3 Error and Retry Flow
- WebSocket disconnects: Show "Reconnecting..." banner. Buffer local operations in an offline queue. On reconnect, resync with server (fetch missed operations, rebase local operations on top).
- Operation rejected by server: If the server rejects an operation (schema violation, permission issue), rollback the local model to the last confirmed state. Show error toast.
- Document save fails: Show "Unable to save — retrying..." banner. Auto-retry with exponential backoff. Keep buffer of unsaved operations.
- Image upload fails: Show error overlay on the image with retry button. Don't block editing — user can continue typing.
- Conflict during reconnect: If the user edited extensively offline and reconnects, the OT/CRDT engine rebases all local operations against the server's current state. If this produces unexpected content, the user can use undo or version history to recover.
7. Data Modelling (Frontend Perspective)
7.1 Core Data Entities
- Document — the top-level entity: metadata + content tree.
- Node — a single element in the document tree (paragraph, heading, text, image, table, etc.).
- Mark — an inline formatting annotation on a text node (bold, italic, link, etc.).
- Operation — a single atomic change to the document (insert, delete, format).
- Comment — a comment anchored to a text range, with replies.
- Collaborator — a remote user with cursor position, selection, and presence.
7.2 Data Shape
// Document metadata
type DocumentMeta = {
id: string;
title: string;
ownerId: string;
createdAt: string;
updatedAt: string;
permission: 'viewer' | 'commenter' | 'editor';
collaborators: CollaboratorInfo[];
};
// Document content (the tree model)
type DocumentContent = {
type: 'doc';
content: BlockNode[];
};
// Block node types
type BlockNode =
| ParagraphNode
| HeadingNode
| BulletListNode
| OrderedListNode
| ListItemNode
| CodeBlockNode
| BlockquoteNode
| TableNode
| ImageNode
| HorizontalRuleNode;
type ParagraphNode = {
type: 'paragraph';
content: InlineNode[];
};
type HeadingNode = {
type: 'heading';
attrs: { level: 1 | 2 | 3 | 4 | 5 | 6 };
content: InlineNode[];
};
// Inline node types
type InlineNode = TextNode | HardBreakNode;
type TextNode = {
type: 'text';
text: string;
marks: Mark[];
};
// Marks (inline formatting)
type Mark =
| { type: 'bold' }
| { type: 'italic' }
| { type: 'underline' }
| { type: 'strikethrough' }
| { type: 'code' }
| { type: 'link'; attrs: { href: string; title?: string } }
| { type: 'textColor'; attrs: { color: string } }
| { type: 'highlight'; attrs: { color: string } }
| { type: 'comment'; attrs: { commentId: string } };
// Operations (for OT)
type Operation =
| { type: 'retain'; count: number }
| { type: 'insert'; text: string; marks?: Mark[] }
| { type: 'delete'; count: number }
| { type: 'insertNode'; node: BlockNode }
| { type: 'deleteNode'; count: number }
| { type: 'addMark'; from: number; to: number; mark: Mark }
| { type: 'removeMark'; from: number; to: number; mark: Mark }
| { type: 'setBlockType'; pos: number; type: string; attrs?: Record<string, any> };
// Collaborator presence
type CollaboratorPresence = {
userId: string;
name: string;
color: string; // unique color per user
avatar: string; // avatar URL for the presence chip / cursor label
cursor: number | null; // model position
selection: { anchor: number; head: number } | null;
status: 'active' | 'idle' | 'away';
isTyping: boolean; // show a "typing…" indicator on the cursor label
lastActive: number; // timestamp
};
// Comment
type Comment = {
id: string;
author: { id: string; name: string; avatar: string };
text: string;
anchor: { from: number; to: number }; // position in document model
createdAt: string;
resolved: boolean;
replies: CommentReply[];
};
type CommentReply = {
id: string;
author: { id: string; name: string; avatar: string };
text: string;
createdAt: string;
};
7.3 Entity Relationships
- One-to-Many: One
Document→ manyBlockNodeitems (content tree). - One-to-Many: One
TextNode→ manyMarkitems (inline formatting). - One-to-Many: One
Document→ manyCommentitems. - One-to-Many: One
Comment→ manyCommentReplyitems. - Many-to-Many:
Comment.anchorreferences a range of document positions — as the document changes, these anchors must be remapped. - Nested:
Table→TableRow→TableCell→Block[](cells contain sub-documents).
Normalized vs Denormalized:
The document content is a denormalized tree (each node contains its children inline). This is necessary because the tree structure IS the content — you need the full tree to render the document. There's no benefit to normalizing it.
Collaborator presence is stored in a flat Map keyed by userId — it's ephemeral and frequently updated.
7.4 UI Specific Data Models
// Toolbar state (derived from current selection)
type ToolbarState = {
isBold: boolean;
isItalic: boolean;
isUnderline: boolean;
isStrikethrough: boolean;
isCode: boolean;
currentBlockType: string; // "paragraph", "heading", etc.
headingLevel: number | null;
textAlign: string;
fontFamily: string;
fontSize: number;
textColor: string;
highlightColor: string;
isLink: boolean;
linkHref: string | null;
canUndo: boolean;
canRedo: boolean;
listType: 'bullet' | 'ordered' | 'checklist' | null;
};
// Derived from the document model + current selection, recomputed on every selection change.
// Editor viewport state (for large document optimization)
type ViewportState = {
scrollTop: number;
visibleBlockRange: { start: number; end: number }; // which blocks are in the viewport
};
8. State Management Strategy
8.1 State Classification
| State Type | Examples | Storage |
|---|---|---|
| Document State | Content tree (all nodes, marks) | Editor core (in-memory); synced via OT/CRDT |
| Collaboration State | Connected users, their cursors/selections | In-memory Map; updated via WebSocket |
| Editor UI State | Active tool, toolbar state, open dialog | Derived from selection + local useState
|
| Comment State | Comment threads linked to document ranges | Server-fetched; React Query cache |
| Component Local State | Dropdown open, search input value |
useState / useReducer
|
| Derived State | Toolbar active states, word count, outline | Computed from document model + selection |
8.2 State Ownership
- EditorCore (custom class, not React) owns the document model, selection, and undo history. It is the single source of truth for document content. React components read from it but never directly mutate it.
- CollaborationProvider (React context) owns the WebSocket connection, OT client, and presence state. It listens for remote operations and feeds them into the EditorCore.
- Toolbar derives its state from the EditorCore's current selection. When the user clicks a format button, Toolbar dispatches a formatting operation to the EditorCore.
- CommentSidebar manages comment data via React Query (server-fetched). Comment anchors are stored as document positions and remapped when the document changes.
Data flow:
Input event → EditorCore.applyOperation() → Model updated
↓
EditorCore emits "change" event
↓
Content canvas repaints the dirty region; React chrome (toolbar, overlays)
re-renders via subscription (useEditorState hook); a11y mirror synced
↓
CollaborationProvider sends operation to server
↓
Server transforms + broadcasts
↓
CollaborationProvider receives remote op → EditorCore.applyRemoteOperation()
↓
Model updated → canvas repaints dirty region + a11y mirror synced; React chrome updates
8.3 Persistence Strategy
| Data | Persistence | Reason |
|---|---|---|
| Document content | Server-synced via OT/CRDT + periodic snapshots | Authoritative server state; collaboration requires it |
| Pending operations (offline) | In-memory buffer; IndexedDB for crash recovery | Preserve user work if tab closes before sync |
| User cursor/selection | WebSocket (ephemeral) | Only relevant while connected |
| Comments | Server via REST API | Persistent, shared across sessions |
| Document metadata | Server via REST API | Title, permissions, last modified |
| Editor preferences (font zoom, spell check) | localStorage | Per-user, per-device |
| Version history | Server | Complete change history |
9. Real Time Collaboration Deep Dive
This is the most technically distinctive aspect of Google Docs. The problem: how do N users edit the same document simultaneously without losing anyone's work, without locking any region, and with sub-second propagation of every edit?
9.1 The Fundamental Problem of Concurrent Editing
Initial document: "ABCD" (indices: A=0, B=1, C=2, D=3)
User A (New York) inserts "X" at index 1.
User B (London) deletes the character at index 2 (the "C").
Neither has seen the other's edit, so BOTH ops are based on "ABCD".
Naively applying the RAW operations (no transformation) in different orders
DIVERGES — the same raw index means different things after a concurrent edit:
Order 1 — A then B (B's raw op = "delete index 2"):
"ABCD" → apply A (insert X at 1) → "AXBCD"
"AXBCD" → delete index 2 → deletes "B" → "AXCD" ✗ (B meant to delete "C")
Order 2 — B then A (A's raw op = "insert X at index 1"):
"ABCD" → apply B (delete index 2) → "ABD"
"ABD" → insert X at index 1 → "AXBD"
Result: "AXCD" ≠ "AXBD" → DIVERGENCE. The two clients no longer agree.
A second flavor of divergence: two inserts at the SAME position.
User A inserts "X" at index 2, User B inserts "Y" at index 2.
Without a deterministic tie-break rule, one client yields "...XY..." and the
other "...YX...". → DIVERGENCE again.
The fundamental requirement: all clients must converge to the same document state, regardless of the order operations are received.
Two algorithms solve this: Operational Transformation (OT) and CRDTs (Conflict-free Replicated Data Types).
9.2 Operational Transformation (OT)
OT is the algorithm used by the original Google Docs (and Google Wave before it). The core idea: when you receive a remote operation, transform it against any local operations that the remote user hasn't seen yet, so that the transformed operation produces the correct result in your local context.
9.2.1 What is an Operation
An operation is a sequence of components that walk through the document from start to end:
type OTOperation = OTComponent[];
type OTComponent =
| { type: 'retain'; count: number } // skip N characters (no change)
| { type: 'insert'; text: string } // insert text at current position
| { type: 'delete'; count: number }; // delete N characters at current position
In classic text OT (the model used here), every operation must exactly span the entire document length. This "total span" invariant is specific to the flat retain/insert/delete representation — other OT formulations (e.g. tree/JSON OT, or index-addressed operations) drop it and address positions directly. We use the total-span form because it makes the transform function clean to reason about.
Example: Document is "HELLO" (length 5). User inserts "X" at position 2:
Operation: [
{ type: 'retain', count: 2 }, // skip "HE"
{ type: 'insert', text: 'X' }, // insert "X" → now "HEXLLO"
{ type: 'retain', count: 3 }, // skip "LLO"
]
// Input length: 5 (2 + 3 retained)
// Output length: 6 (2 retained + 1 inserted + 3 retained)
Example: Delete character at position 3 from "HELLO":
Operation: [
{ type: 'retain', count: 3 }, // skip "HEL"
{ type: 'delete', count: 1 }, // delete "L"
{ type: 'retain', count: 1 }, // skip "O"
]
// Input length: 5 (3 + 1 + 1)
// Output length: 4 (3 retained + 1 retained, 1 deleted)
9.2.2 The Transform Function
The transform function is the heart of OT. Given two concurrent operations A and B (both based on the same document state), transform produces A' and B' such that:
apply(apply(doc, A), B') === apply(apply(doc, B), A')
This is the convergence property — no matter what order the operations are applied, the result is the same.
doc
/ \
A B
/ \
docA docB
\ /
B' A'
\ /
docAB === docBA
Transform(Insert, Insert)
Document: "AB"
User A: insert "X" at position 1 → [retain(1), insert("X"), retain(1)]
User B: insert "Y" at position 1 → [retain(1), insert("Y"), retain(1)]
Both are concurrent (based on "AB").
Transform result:
A' = [retain(1), insert("X"), retain(2)] // retain 2 because Y was inserted
B' = [retain(2), insert("Y"), retain(1)] // retain 2 because X was inserted
Apply A then B':
"AB" → "AXAB" ... wait, that's wrong.
Let's trace more carefully:
"AB" → apply A → "AXB" (insert X at 1)
"AXB" → apply B' → B' needs to account for X being inserted before Y's position
B' = [retain(2), insert("Y"), retain(1)]
"AXB" → retain 2 ("AX") → insert "Y" → retain 1 ("B") → "AXYB"
Apply B then A':
"AB" → apply B → "AYB" (insert Y at 1)
"AYB" → apply A' → A' = [retain(1), insert("X"), retain(2)]
"AYB" → retain 1 ("A") → insert "X" → retain 2 ("YB") → "AXYB"
Both paths → "AXYB" ✅ CONVERGED!
Tie-breaking rule: when two inserts happen at the same position, the server decides who goes first (usually by user ID or timestamp). This ensures deterministic ordering.
Transform(Insert, Delete)
Document: "ABCD"
User A: insert "X" at position 1 → [retain(1), insert("X"), retain(3)]
User B: delete at position 2 → [retain(2), delete(1), retain(1)]
Transform:
A' against B: B deleted at position 2. A inserts at position 1 (before the delete).
A's position is unaffected. But the document is now shorter (3 chars).
A' = [retain(1), insert("X"), retain(2)] // retain 2 instead of 3 (one char deleted)
B' against A: A inserted at position 1. B deletes at position 2, but since A's insert
shifted everything after position 1 to the right by 1, B's delete position becomes 3.
B' = [retain(3), delete(1), retain(1)]
Apply A then B':
"ABCD" → A → "AXBCD" → B' → retain 3 ("AXB"), delete 1 ("C"), retain 1 ("D") → "AXBD"
Apply B then A':
"ABCD" → B → "ABD" → A' → retain 1 ("A"), insert "X", retain 2 ("BD") → "AXBD"
Both → "AXBD" ✅ CONVERGED!
Transform Implementation
function transform(
opA: OTComponent[],
opB: OTComponent[],
priority: 'left' | 'right' = 'left' // tie-breaker for same-position inserts
): [OTComponent[], OTComponent[]] {
const aPrime: OTComponent[] = [];
const bPrime: OTComponent[] = [];
let indexA = 0;
let indexB = 0;
let compA = opA[indexA];
let compB = opB[indexB];
while (indexA < opA.length || indexB < opB.length) {
// Case 1: A is inserting (inserts don't consume input, so B must account for them)
if (compA && compA.type === 'insert') {
aPrime.push(compA);
bPrime.push({ type: 'retain', count: compA.text.length });
compA = opA[++indexA];
continue;
}
// Case 2: B is inserting
if (compB && compB.type === 'insert') {
bPrime.push(compB);
aPrime.push({ type: 'retain', count: compB.text.length });
compB = opB[++indexB];
continue;
}
// Case 3: Both retain
if (compA?.type === 'retain' && compB?.type === 'retain') {
const len = Math.min(compA.count, compB.count);
aPrime.push({ type: 'retain', count: len });
bPrime.push({ type: 'retain', count: len });
// Consume min length from both
compA = consumeComponent(compA, len, opA, indexA);
compB = consumeComponent(compB, len, opB, indexB);
if (compA?.count === 0) compA = opA[++indexA];
if (compB?.count === 0) compB = opB[++indexB];
continue;
}
// Case 4: A retains, B deletes
if (compA?.type === 'retain' && compB?.type === 'delete') {
const len = Math.min(compA.count, compB.count);
bPrime.push({ type: 'delete', count: len });
// A's retain is consumed by B's delete — nothing added to aPrime for this range
compA = { ...compA, count: compA.count - len };
compB = { ...compB, count: compB.count - len };
if (compA.count === 0) compA = opA[++indexA];
if (compB.count === 0) compB = opB[++indexB];
continue;
}
// Case 5: A deletes, B retains (symmetric to case 4)
if (compA?.type === 'delete' && compB?.type === 'retain') {
const len = Math.min(compA.count, compB.count);
aPrime.push({ type: 'delete', count: len });
compA = { ...compA, count: compA.count - len };
compB = { ...compB, count: compB.count - len };
if (compA.count === 0) compA = opA[++indexA];
if (compB.count === 0) compB = opB[++indexB];
continue;
}
// Case 6: Both delete at the same position — they cancel each other
if (compA?.type === 'delete' && compB?.type === 'delete') {
const len = Math.min(compA.count, compB.count);
compA = { ...compA, count: compA.count - len };
compB = { ...compB, count: compB.count - len };
if (compA.count === 0) compA = opA[++indexA];
if (compB.count === 0) compB = opB[++indexB];
continue;
}
break; // shouldn't reach here if operations are valid
}
return [aPrime, bPrime];
}
9.2.3 OT with a Central Server
Google Docs uses a central server model for OT. The server is the single source of truth and assigns a linear order to all operations (a revision number):
┌────────────┐ ┌────────────────┐ ┌────────────┐
│ Client A │ │ Server │ │ Client B │
│ (rev 5) │ │ (rev 5) │ │ (rev 5) │
└─────┬──────┘ └───────┬────────┘ └─────┬──────┘
│ │ │
│ op_A (based on rev 5) │ │
│─────────────────────────────►│ │
│ │ op_B (based on rev 5) │
│ │◄──────────────────────────────│
│ │ │
│ Server receives op_A first: │
│ 1. Apply op_A → rev 6 │
│ 2. Broadcast op_A to Client B │
│ │ │
│ Server receives op_B (based on rev 5): │
│ 3. Transform op_B against op_A │
│ → op_B' (now based on rev 6) │
│ 4. Apply op_B' → rev 7 │
│ 5. Broadcast op_B' to Client A │
│ │ │
│ ack(rev 6) ← confirm op_A │ │
│◄─────────────────────────────│ │
│ │ op_A (broadcast to B) │
│ │──────────────────────────────►│
│ op_B' (transformed) │ │
│◄─────────────────────────────│ │
│ │ ack(rev 7) ← confirm op_B' │
│ │──────────────────────────────►│
Key properties of server-based OT:
- The server always has the authoritative document state.
- Each operation is assigned a monotonically increasing revision number.
- Operations from clients carry the revision they're based on. If it's not the latest, the server transforms them.
- Clients receive either acknowledgments (for their own ops) or remote operations (from others).
9.2.4 Complete OT Client Implementation
The OT client is a state machine with three states:
┌──────────────────────┐
│ SYNCHRONIZED │ Client is in sync with server.
│ (no pending ops) │ Local revision === server revision.
└──────────┬───────────┘
│ User edits locally
▼
┌──────────────────────┐
│ AWAITING_CONFIRM │ Client has sent an operation to server.
│ (op in flight) │ Waiting for acknowledgment.
└──────────┬───────────┘
│ User edits again before ack arrives
▼
┌──────────────────────┐
│ AWAITING_WITH_ │ Client has sent one op AND has a
│ BUFFER │ second op buffered locally.
│ (op in flight + │ When ack arrives, buffer is sent.
│ buffered op) │
└──────────────────────┘
type ClientState =
| { type: 'synchronized' }
| { type: 'awaitingConfirm'; outstanding: OTOperation }
| { type: 'awaitingWithBuffer'; outstanding: OTOperation; buffer: OTOperation };
class OTClient {
private state: ClientState = { type: 'synchronized' };
private revision: number;
private doc: string; // simplified — real system uses document model
constructor(
private transport: WebSocketTransport,
initialDoc: string,
initialRevision: number
) {
this.doc = initialDoc;
this.revision = initialRevision;
// Listen for server messages
transport.on('ack', () => this.handleAck());
transport.on('operation', (op: OTOperation) => this.handleRemoteOp(op));
}
// User made a local edit — apply it and send/buffer
applyLocal(operation: OTOperation) {
// Apply to local document immediately (instant feedback)
this.doc = applyOperation(this.doc, operation);
switch (this.state.type) {
case 'synchronized':
// No pending ops — send directly
this.transport.send({ op: operation, revision: this.revision });
this.state = { type: 'awaitingConfirm', outstanding: operation };
break;
case 'awaitingConfirm':
// Already have one op in flight — buffer this one
this.state = {
type: 'awaitingWithBuffer',
outstanding: this.state.outstanding,
buffer: operation,
};
break;
case 'awaitingWithBuffer':
// Already buffering — compose the new op with the buffer
this.state = {
...this.state,
buffer: compose(this.state.buffer, operation),
};
break;
}
}
// Server acknowledged our operation
private handleAck() {
this.revision++;
switch (this.state.type) {
case 'awaitingConfirm':
// Our op is confirmed — back to synchronized
this.state = { type: 'synchronized' };
break;
case 'awaitingWithBuffer':
// Our outstanding op is confirmed — send the buffer
this.transport.send({ op: this.state.buffer, revision: this.revision });
this.state = {
type: 'awaitingConfirm',
outstanding: this.state.buffer,
};
break;
default:
throw new Error('Received ack in unexpected state');
}
}
// Received a remote operation from another user
private handleRemoteOp(serverOp: OTOperation) {
this.revision++;
switch (this.state.type) {
case 'synchronized':
// No local pending ops — apply server op directly
this.doc = applyOperation(this.doc, serverOp);
this.emitChange();
break;
case 'awaitingConfirm': {
// Transform server op against our outstanding op
const [outstandingPrime, serverOpPrime] = transform(
this.state.outstanding,
serverOp
);
this.state = { type: 'awaitingConfirm', outstanding: outstandingPrime };
this.doc = applyOperation(this.doc, serverOpPrime);
this.emitChange();
break;
}
case 'awaitingWithBuffer': {
// Transform against outstanding, then against buffer
const [outstandingPrime, serverOp1] = transform(
this.state.outstanding,
serverOp
);
const [bufferPrime, serverOp2] = transform(this.state.buffer, serverOp1);
this.state = {
type: 'awaitingWithBuffer',
outstanding: outstandingPrime,
buffer: bufferPrime,
};
this.doc = applyOperation(this.doc, serverOp2);
this.emitChange();
break;
}
}
}
}
Why Three States?
Q: Why not just send every operation immediately?
A: If we send op1 and then immediately send op2 (before op1 is acknowledged),
the server might process op2 before op1 (network reordering), or transform
op2 against a different base than expected. The three-state design ensures:
1. Only ONE operation is in flight at a time.
2. Additional edits are buffered and composed.
3. After ack, the buffer is sent as the next operation.
This guarantees correct ordering and transformation.
State Machine Visualization
┌──────────────┐
┌───►│ SYNCHRONIZED │◄──────┐
│ └──────┬───────┘ │
│ │ local edit │ ack (no buffer)
│ ▼ │
│ ┌──────────────────┐ │
│ │ AWAITING_CONFIRM │───┘
│ └──────┬───────────┘
│ │ local edit
│ ▼
│ ┌──────────────────────┐
│ │ AWAITING_WITH_BUFFER │◄─┐
│ └──────┬───────────────┘ │
│ │ ack │ local edit
│ │ (send buffer) │ (compose into buffer)
│ ▼ │
│ ┌──────────────────┐ │
└────│ AWAITING_CONFIRM │──────┘
└──────────────────┘
9.2.5 Server Side OT Orchestration
The server maintains the canonical operation log — an ordered list of all operations, each with a revision number:
// Simplified server-side OT handler
class OTServer {
private operations: OTOperation[] = []; // canonical operation log
private document: string; // current document state
handleClientOperation(clientOp: OTOperation, clientRevision: number, clientId: string) {
// Client's operation is based on revision `clientRevision`.
// Server's current revision is `this.operations.length`.
// Transform the client's op against all ops it has missed.
let transformedOp = clientOp;
for (let i = clientRevision; i < this.operations.length; i++) {
const [, serverOpTransformed] = transform(this.operations[i], transformedOp);
transformedOp = serverOpTransformed;
// Note: we need the second output (transformedOp adjusted for the server op)
}
// Apply the fully transformed operation
this.document = applyOperation(this.document, transformedOp);
this.operations.push(transformedOp);
// Send ack to the author
this.send(clientId, { type: 'ack' });
// Broadcast to all other clients
this.broadcast(clientId, { type: 'operation', op: transformedOp });
}
}
9.3 CRDTs (Conflict Free Replicated Data Types)
CRDTs are an alternative to OT that is gaining popularity for real-time collaboration. Unlike OT (which transforms operations), CRDTs design the data structure itself so that concurrent edits automatically converge without transformation.
9.3.1 How CRDTs Differ from OT
| Aspect | OT | CRDT |
|---|---|---|
| Core idea | Transform operations against each other to account for concurrency | Design data types that mathematically guarantee convergence |
| Server requirement | Central server is needed to order operations | No central server needed (peer-to-peer works) |
| Operation complexity | Operations are simple (insert, delete). Transform function is complex. | Operations are simple. Data structure carries unique IDs and metadata. |
| Metadata overhead | Minimal — operations are lightweight | Each character may carry a unique ID (increased memory) |
| Deletion | Physical deletion (character is removed) | Tombstoning (deleted characters are marked, not removed) |
| Used by | Google Docs, Google Wave | Yjs, Automerge, Apple Notes, Figma (for some features) |
| Undo complexity | Complex (must invert and transform against concurrent ops) | Complex (must track causal history) |
| Offline support | Limited (requires server for transformation) | Excellent (edits can be merged peer-to-peer on reconnect) |
9.3.2 Sequence CRDTs for Text (Yjs Example)
For text editing, we need a sequence CRDT — a data structure where elements (characters) can be inserted and deleted at any position, and concurrent operations always produce the same result on all replicas.
There is a whole family of sequence-CRDT algorithms; naming a couple signals depth in an interview:
| Algorithm | Positioning idea | Notes |
|---|---|---|
| RGA (Replicated Growable Array) | Each element points to the id it was inserted after; concurrent inserts at the same spot are ordered by id | Used inside Yjs/Automerge-style engines; tombstones on delete |
| YATA (Yjs) | Like RGA but tracks both left/right origins; conflict resolution by client id | The algorithm Yjs implements; very fast in practice |
| Logoot / LSEQ | Each element gets a dense, unbounded fractional position identifier between its neighbors | No tombstones for ordering, but identifiers can grow; LSEQ adaptively allocates to bound growth |
| Treedoc | Positions are paths in a binary tree | Elegant, but rebalancing is tricky |
| Automerge | Columnar RGA-based document CRDT | Great for local-first/offline; higher memory overhead |
The rest of this section uses Yjs (YATA) as the concrete example. The key insight across all of them: instead of using index positions (which shift when characters are inserted/deleted by others), each character gets a globally unique ID that never changes.
How Yjs Represents Text
Traditional text: "HELLO"
index: 0 1 2 3 4
Problem: if User B inserts at index 2, all indices after 2 change.
CRDT text (Yjs): "HELLO"
Each character has a unique, stable identifier:
'H' → { id: (clientA, clock=0) }
'E' → { id: (clientA, clock=1) }
'L' → { id: (clientA, clock=2) }
'L' → { id: (clientA, clock=3) }
'O' → { id: (clientA, clock=4) }
Insert "X" between "E" and "L":
New item: { id: (clientB, clock=0), content: 'X', origin: (clientA, clock=1) }
Result: H E X L L O
No index shifting — X is positioned by its relationship to "E" (its origin).
Concurrent Insert Resolution
Document: "AB"
User A inserts "X" between A and B:
{ id: (A, 0), content: 'X', originLeft: idOfA, originRight: idOfB }
User B inserts "Y" between A and B:
{ id: (B, 0), content: 'Y', originLeft: idOfA, originRight: idOfB }
Both X and Y are between A and B. Which goes first?
CRDT resolution rule: compare client IDs.
If clientA < clientB → X comes before Y: "AXYB"
This is deterministic — all clients produce the same result.
No transformation needed. Just insert both items and let the CRDT's ordering rules sort them.
9.3.3 CRDT Integration with Editor
Using Yjs with a ProseMirror-based editor:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror';
function setupCollaborativeEditor(docId: string) {
// Create a Yjs document
const ydoc = new Y.Doc();
// The document content is a Yjs XML Fragment (maps to ProseMirror's document model)
const yxml = ydoc.getXmlFragment('prosemirror');
// WebSocket provider for real-time sync
const provider = new WebsocketProvider(
'wss://collab.example.com',
docId,
ydoc
);
// Set user awareness (cursor, selection, name, color)
provider.awareness.setLocalStateField('user', {
name: currentUser.name,
color: currentUser.color,
cursor: null,
});
// Create ProseMirror editor with Yjs plugins
const editor = new EditorView(document.getElementById('editor'), {
state: EditorState.create({
schema: editorSchema,
plugins: [
// Syncs ProseMirror's document model with the Yjs CRDT
ySyncPlugin(yxml),
// Shows remote cursors and selections
yCursorPlugin(provider.awareness),
// Wires undo/redo to Yjs's undo manager (not ProseMirror's default)
yUndoPlugin(),
// ... other plugins (keymap, input rules, etc.)
],
}),
});
return { editor, ydoc, provider };
}
How the sync plugin works:
- User types in ProseMirror → ProseMirror creates a transaction.
-
ySyncPluginintercepts the transaction and converts it to Yjs operations onyxml. - Yjs propagates the changes to the
WebsocketProvider. - Provider sends the changes to the server → server relays to other clients.
- Other clients' Yjs instances receive changes →
ySyncPluginconverts them to ProseMirror transactions → editor updates.
9.4 OT vs CRDT Comparison
| Criterion | OT (Google Docs approach) | CRDT (Yjs / Automerge approach) |
|---|---|---|
| Convergence guarantee | Requires correct transform function (hard to prove, easy to have bugs) | Mathematically guaranteed by the data type design |
| Central server | Required (assigns operation order) | Optional (works peer-to-peer) |
| Offline support | Weak — must sync through server | Strong — merge locally, sync when online |
| Memory overhead | Low — operations are lightweight | Higher — each character carries metadata (unique ID, tombstones) |
| Large documents | Efficient (operations on a flat string) | Can be slower (tree traversal for large CRDT structures) |
| Undo/redo | Complex but well-understood | Very complex (must track causal dependencies) |
| Maturity | 20+ years (Google Wave in 2009) | 10+ years (academic); Yjs mature since ~2019 |
| Implementation effort | High (correct transform functions are subtle) | Moderate (use a library like Yjs) |
| Best for | Centralized apps (Google Docs, Notion) | Decentralized/local-first apps (Excalidraw, linear, some Figma features) |
For Google Docs specifically, OT is the right choice:
- Google already has robust server infrastructure.
- OT has lower memory overhead (important for 100-page documents).
- Server-authoritative model enables features like version history, permission enforcement, and server-side spell check.
For a startup building a collaborative editor, CRDT (via Yjs) is often the practical choice:
- Much easier to implement correctly (just use the library).
- Better offline support out of the box.
- No need for a complex OT server.
9.5 Cursor and Selection Presence
Each collaborator's cursor position and selection must be visible to all other users in real time.
How Cursor Positions Are Shared
User A types → cursor at model position 42
↓
Client A sends presence update via WebSocket:
{ userId: "A", cursor: 42, selection: null, name: "Alice", color: "#e06666" }
↓
Server relays to all other clients
↓
Client B receives → renders Alice's cursor at position 42 with a colored flag
How Cursor Positions Survive Edits
When a remote operation changes the document, all cursor positions might shift. We must map cursor positions through the operation:
User B's cursor is at position 10.
User A inserts 5 characters at position 3.
User B's cursor should now be at position 15 (shifted right by 5).
Mapping function:
newPosition = mapPositionThroughOperation(oldPosition, operation)
function mapPosition(pos: number, operation: OTComponent[]): number {
let currentPos = 0;
let newPos = pos;
for (const comp of operation) {
if (currentPos >= pos) break;
switch (comp.type) {
case 'retain':
currentPos += comp.count;
break;
case 'insert':
// Insert happened before our position — shift right
if (currentPos <= pos) {
newPos += comp.text.length;
}
break;
case 'delete':
// Delete happened before our position — shift left
const deleteEnd = currentPos + comp.count;
if (deleteEnd <= pos) {
newPos -= comp.count;
} else if (currentPos <= pos && deleteEnd > pos) {
// Our cursor was inside the deleted range — move to start of deletion
newPos = currentPos;
}
currentPos += comp.count;
break;
}
}
return Math.max(0, newPos);
}
Rendering Remote Cursors
Remote cursors and selections are drawn on a transparent overlay that sits on top of the content canvas. Two common implementations: (a) paint them directly onto a second overlay <canvas> layered above the content canvas, or (b) render lightweight absolutely-positioned DOM chips (shown below — easy to add hover tooltips / name labels). Either way, the pixel position comes from the layout geometry map (positionToPixel), not from the DOM — because the content itself has no DOM nodes to anchor to.
// DOM-overlay variant (chips positioned from the geometry map)
function CursorOverlay({ collaborators }: { collaborators: CollaboratorPresence[] }) {
return (
<>
{collaborators.map((collab) => {
if (collab.cursor == null) return null;
// Convert model position to pixel coordinates via the layout geometry map
const coords = geometryMap.positionToPixel(collab.cursor);
if (!coords) return null; // off-screen (outside painted viewport)
return (
<div key={collab.userId} className="remote-cursor" style={{
position: 'absolute',
left: coords.left,
top: coords.top,
pointerEvents: 'none',
}}>
{/* Blinking cursor line */}
<div style={{
width: 2,
height: coords.lineHeight,
backgroundColor: collab.color,
}} />
{/* Name label */}
<div style={{
backgroundColor: collab.color,
color: 'white',
fontSize: 11,
padding: '1px 4px',
borderRadius: 3,
whiteSpace: 'nowrap',
transform: 'translateY(-100%)',
}}>
{collab.name}
</div>
</div>
);
})}
</>
);
}
Remote Selection Highlighting
When a collaborator selects a range of text, the selection is painted as a set of semi-transparent colored rectangles behind the glyphs. With canvas rendering we compute those rectangles from the layout geometry map (there is no DOM Range to query):
// Convert a model range (anchor, head) to visual rectangles using the geometry map.
// A multi-line selection produces one rect per line it spans.
function getSelectionRects(from: number, to: number): Rect[] {
const rects: Rect[] = [];
// The layout engine knows, per line, the pixel box for any sub-range on that line.
for (const line of geometryMap.linesInRange(from, to)) {
const start = Math.max(from, line.from);
const end = Math.min(to, line.to);
rects.push(geometryMap.rangeToRect(start, end)); // { x, y, width, height }
}
return rects;
}
// Painted on the overlay canvas:
for (const rect of getSelectionRects(anchor, head)) {
ctx.fillStyle = withAlpha(collab.color, 0.25);
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
}
9.6 WebSocket Transport Layer
A production collaboration socket is more than "connect and send." Interviewers expect these connection-management concerns:
- Heartbeat (ping/pong) — send a periodic ping (e.g. every 20–30s); if no pong arrives within a timeout, treat the connection as dead and reconnect. This detects half-open connections that
onclosenever fires for. - Reconnect with backoff — exponential backoff with jitter (capped), as implemented below.
- Session resume — on reconnect, the client sends its last acknowledged revision number so the server can replay only the operations the client missed, instead of resending the whole document.
- Message sequence ids — each message carries a monotonically increasing sequence id so the client can detect gaps/reordering and request a resync, and the server can deduplicate operations replayed after a flaky reconnect (idempotency).
class CollaborationTransport {
private ws: WebSocket;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private offlineBuffer: OTOperation[] = [];
connect(docId: string) {
this.ws = new WebSocket(`wss://collab.example.com/doc/${docId}`);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
// Flush offline buffer
this.flushOfflineBuffer();
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'ack':
this.emit('ack');
break;
case 'operation':
this.emit('operation', message.op);
break;
case 'presence':
this.emit('presence', message.data);
break;
case 'error':
this.handleServerError(message);
break;
}
};
this.ws.onclose = (event) => {
if (!event.wasClean) {
this.scheduleReconnect();
}
};
}
send(operation: OTOperation, revision: number) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'operation',
op: operation,
revision,
}));
} else {
// Offline — buffer the operation
this.offlineBuffer.push(operation);
}
}
private scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.emit('disconnected');
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
setTimeout(() => {
this.connect(this.docId);
}, delay);
}
private flushOfflineBuffer() {
// On reconnect, we need to resync with the server first,
// then rebase our buffered operations on the latest server state.
// This is handled by the OT client's state machine.
for (const op of this.offlineBuffer) {
this.emit('localBuffered', op);
}
this.offlineBuffer = [];
}
}
9.7 Offline Editing and Reconnection
When the network drops, the user should continue editing seamlessly:
Network drops
↓
1. OT client switches to "offline" mode
2. User continues typing — all operations are applied locally and buffered
3. UI shows "Offline — changes will be saved when you reconnect"
↓
Network returns
↓
4. WebSocket reconnects
5. Client sends: "I'm at revision 42. What have I missed?"
6. Server responds with operations 43, 44, 45, ... (all ops since rev 42)
7. Client transforms its buffered operations against the missed server ops
8. Client sends its rebased operations to the server
9. Server acknowledges → client is back in sync
10. UI shows "All changes saved"
Offline Operation Buffer with IndexedDB
For crash safety, buffered operations are periodically written to IndexedDB:
async function persistOfflineBuffer(buffer: OTOperation[]) {
const db = await openDB('doc-offline', 1, {
upgrade(db) {
db.createObjectStore('operations', { keyPath: 'id', autoIncrement: true });
}
});
const tx = db.transaction('operations', 'readwrite');
for (const op of buffer) {
tx.store.put({ op, timestamp: Date.now() });
}
await tx.done;
}
If the user closes the tab and reopens later, the buffered operations are loaded from IndexedDB and resynced with the server.
10. Undo Redo in Collaborative Context
Undo/redo in a collaborative editor is much harder than in a single-user editor. The key question: what does "undo" mean when other users have edited the document since your last action?
The Problem
User A types "Hello" at the beginning.
User B types "World" at the end.
User A presses Ctrl+Z (undo).
What should happen?
Option 1: Undo A's "Hello" → "World" remains. ✅ This is correct.
Option 2: Undo the LAST operation (B's "World") → wrong! That's B's work.
Undo should only undo YOUR OWN operations, even if other users have since edited the document.
Implementation
class CollaborativeUndoManager {
private undoStack: OTOperation[] = []; // inverse of user's own operations
private redoStack: OTOperation[] = [];
// When the local user performs an edit
record(operation: OTOperation) {
this.undoStack.push(invertOperation(operation));
this.redoStack = []; // clear redo on new edit
}
// When a remote operation arrives, transform undo/redo stacks
transformStacks(remoteOp: OTOperation) {
// Transform every entry in the undo stack against the remote op
this.undoStack = this.undoStack.map((undoOp) => {
const [transformedUndo] = transform(undoOp, remoteOp);
return transformedUndo;
});
this.redoStack = this.redoStack.map((redoOp) => {
const [transformedRedo] = transform(redoOp, remoteOp);
return transformedRedo;
});
}
undo(): OTOperation | null {
const undoOp = this.undoStack.pop();
if (!undoOp) return null;
// Push the inverse (redo version) onto the redo stack
this.redoStack.push(invertOperation(undoOp));
// Return the operation to be applied and sent to the server
return undoOp;
}
redo(): OTOperation | null {
const redoOp = this.redoStack.pop();
if (!redoOp) return null;
this.undoStack.push(invertOperation(redoOp));
return redoOp;
}
}
Why transform the undo stack?
When User B inserts 5 characters at position 3, all positions in User A's undo stack that are >= 3 must shift right by 5. Without transforming, undoing would delete the wrong characters.
11. High Level API Design (Frontend POV)
11.1 Required APIs
| API | Method | Description |
|---|---|---|
/api/docs/:id |
GET | Fetch document content + metadata |
/api/docs/:id |
PUT | Save document snapshot (periodic, not per-keystroke) |
/api/docs |
POST | Create new document |
/api/docs/:id |
DELETE | Delete document |
wss://collab.example.com/doc/:id |
WebSocket | Real-time operation sync and presence |
/api/docs/:id/comments |
GET | Fetch all comments for a document |
/api/docs/:id/comments |
POST | Add a new comment |
/api/docs/:id/comments/:cid |
PUT | Edit or resolve a comment |
/api/docs/:id/comments/:cid/replies |
POST | Add a reply to a comment thread |
/api/docs/:id/versions |
GET | Fetch version history list |
/api/docs/:id/versions/:vid |
GET | Fetch a specific version snapshot |
/api/docs/:id/media |
POST | Upload an image or file |
/api/docs/:id/export |
POST | Export to PDF, DOCX, HTML |
11.2 Request and Response Structure
GET /api/docs/:id
// Response
{
"id": "doc_abc123",
"title": "Q4 Project Plan",
"content": {
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 1 },
"content": [{ "type": "text", "text": "Q4 Project Plan" }]
},
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "This document outlines..." }
]
}
]
},
"revision": 42,
"collaborators": [
{ "userId": "u_1", "name": "Zeeshan Ali", "avatar": "...", "color": "#e06666" }
],
"permission": "editor",
"updatedAt": "2026-03-17T08:00:00Z"
}
WebSocket Messages
// Client → Server: Send operation
{
"type": "operation",
"op": [
{ "type": "retain", "count": 42 },
{ "type": "insert", "text": "Hello" },
{ "type": "retain", "count": 100 }
],
"revision": 42
}
// Server → Client: Acknowledge
{
"type": "ack"
}
// Server → Client: Remote operation
{
"type": "operation",
"op": [
{ "type": "retain", "count": 10 },
{ "type": "delete", "count": 5 },
{ "type": "retain", "count": 137 }
],
"userId": "u_2",
"revision": 43
}
// Client → Server: Presence update
{
"type": "presence",
"cursor": 42,
"selection": { "anchor": 42, "head": 50 }
}
// Server → Client: Remote presence
{
"type": "presence",
"userId": "u_2",
"name": "Ali",
"color": "#6aa84f",
"cursor": 78,
"selection": null
}
11.3 Error Handling and Status Codes
| Status | Scenario | Frontend Handling |
|---|---|---|
200 |
Success | Render document |
304 |
Not Modified | Use cached version |
400 |
Malformed operation | Log error; this indicates a client bug |
401 |
Unauthorized | Redirect to sign-in |
403 |
No permission (viewer trying to edit) | Show "View only" banner; disable editing |
404 |
Document not found | Show "Document not found" error page |
409 |
Revision conflict (rare in OT) | Refetch document and resync |
413 |
Document/media too large | Show "File too large" error |
429 |
Rate limit | Throttle operations; show "Saving paused" |
500 |
Server error | Show "Unable to save — retrying" banner |
WS 1006
|
Abnormal WebSocket close | Auto-reconnect with exponential backoff |
12. Caching Strategy
12.1 What to Cache
- Document content: The source of truth is the collaboration server. Local state is the "cache" — always in sync via OT/CRDT.
- Document metadata (title, permissions): React Query cache, short TTL (1 min).
- Comments: React Query cache keyed by document ID. Refetch on focus.
- User profiles / avatars: Browser HTTP cache + CDN (long TTL).
- Fonts: Service Worker cache (critical for consistent text rendering).
- Application bundle: Service Worker cache for offline access.
12.2 Where to Cache
| Data | Cache Location | TTL |
|---|---|---|
| Document content (active) | In-memory (editor model) | Ephemeral — synced in real time |
| Offline operation buffer | IndexedDB | Until synced |
| Comments | React Query in-memory | 1 min; refetch on focus |
| User avatars | Browser HTTP cache + CDN | 24h |
| Fonts | Service Worker cache | Indefinite (immutable filenames) |
| App shell | Service Worker | Until new version |
| Editor preferences | localStorage | Indefinite |
12.3 Cache Invalidation
- Document content: Not cached traditionally — the OT/CRDT system keeps it in sync. On reconnect, the client fetches missed operations.
- Comments: Invalidated when a new comment is added (optimistic update + background refetch).
- User avatars: Immutable with content-hashed URLs.
- App bundle: Service Worker checks for updates; stale-while-revalidate.
13. CDN and Asset Optimization
- App delivery: Static HTML + JS + CSS served from CDN. The editor is a heavy bundle (~500KB gzipped), so code splitting is critical.
- Font delivery: Custom editor fonts (serif, sans-serif, monospace options) served from CDN with
font-display: swap. Preloaded in<head>since they're critical for text rendering. - Image delivery: Uploaded images are stored in a blob service and served via CDN with signed URLs. Responsive sizes via
srcset. - Cache headers:
- JS/CSS:
Cache-Control: public, max-age=31536000, immutable(content-hashed). - HTML:
Cache-Control: no-cache. - Fonts:
Cache-Control: public, max-age=31536000, immutable. - API responses:
Cache-Control: private, no-cache.
- JS/CSS:
- Compression: Brotli for all text-based assets. Document content over WebSocket is also compressed (permessage-deflate).
14. Rendering Strategy
- CSR (client-side rendering) only. A minimal static app shell (HTML + JS + CSS) is served from the CDN and boots on the client; the editor chrome renders immediately, then the document model is initialized client-side from the fetched JSON and painted onto the content
<canvas>. There's no SSR because:- The content changes constantly (collaboration invalidates any server-rendered content instantly).
- The content is painted on canvas from the model on the client — there is no server-paintable HTML to hydrate.
- Docs are auth-gated, so there's no SEO benefit to server-rendering content.
- The editor fetches the structured model JSON regardless (needed to type/undo/apply OT); once it has that, painting the visible viewport is single-digit milliseconds.
- Code splitting:
- Core editor + toolbar: main chunk.
- Collaboration module: lazy-loaded on WebSocket connect.
- Comments sidebar: lazy-loaded when user opens comments.
- Export module: lazy-loaded when user opens File → Export.
- Version history: lazy-loaded when user opens the panel.
- Viewport painting for large documents: For 100+ page documents, the canvas only paints the blocks intersecting the viewport (+ a buffer above and below). The layout engine still computes total document height (so the scrollbar is correct), but off-screen blocks are laid out lazily/coarsely and never painted until scrolled into view. Because content is pixels, there are no off-screen DOM nodes to manage at all — the DOM stays tiny regardless of document size.
15. Cross Cutting Non Functional Concerns
15.1 Security
- Access control: Permission level (viewer/commenter/editor) is enforced on both client and server. The editor UI disables editing for viewers. The server rejects operations from non-editors.
- XSS prevention: User-generated content is rendered from the typed document model (not arbitrary HTML). Pasted HTML is sanitized and converted to the model's schema — any script tags, event handlers, or dangerous elements are stripped.
- CSRF: All API calls include a CSRF token. WebSocket connection is authenticated via the same session.
- Token storage: Auth tokens in HTTP-only cookies. No storage of sensitive data in localStorage.
- Content isolation: Documents from different users/orgs are isolated at the API level. The frontend has no access to data outside the current document.
- Clipboard sanitization: When pasting from external sources, parse HTML through a whitelist of allowed tags and attributes. Disallow
<script>,<iframe>,<form>,on*attributes.
15.2 Accessibility
Because the visible content is a <canvas>, it is opaque to assistive technology — a screen reader sees a single image, not text. Accessibility is therefore driven by a parallel, offscreen "mirror" DOM kept in sync with the document model. This is exactly how real Google Docs supports screen readers with a canvas renderer.
- Offscreen accessibility mirror DOM: For the region around the caret (and on demand as the user navigates), the editor generates real semantic HTML from the model —
<h1>–<h6>,<p>,<ul>/<ol>/<li>,<a>,<table>/<tr>/<td>— inside an offscreen container marked up asrole="document"/role="textbox". The screen reader reads this tree; edits to the model update it. It is visually hidden (notdisplay:none, which would hide it from AT) but exposed to the accessibility tree. - Caret ↔ AT sync: as the painted caret moves, the corresponding node/offset in the mirror DOM is focused/selected so the screen reader announces the right context.
- Keyboard navigation:
- Standard keyboard shortcuts: Ctrl+B (bold), Ctrl+I (italic), Ctrl+Z (undo), etc.
- Tab/Shift+Tab in lists for indent/outdent.
- Caret-browsing / arrow-key navigation is fully handled by the editor (the hidden input proxy holds focus).
- Toolbar navigation via Tab + arrow keys (roving tabindex) — the toolbar is ordinary DOM, so it's natively accessible.
- Screen reader support:
- Live announcements for collaborator actions: "Alice started editing" (via
aria-live="polite"). - Format state announced when the caret moves: "Bold, Heading 2."
- Comment/suggestion markers surfaced through the mirror DOM.
- Live announcements for collaborator actions: "Alice started editing" (via
- Focus management: When dialogs open (Insert Image, Share), focus is trapped. On close, focus returns to the hidden input proxy so typing resumes.
- Color contrast: Toolbar icons, comment text, and status messages meet WCAG AA. Collaborator colors are chosen from an accessible palette. (Note: canvas text bypasses browser high-contrast/forced-colors modes, so the editor must detect
forced-colorsand repaint with system colors.)
15.3 Performance Optimization
Typing Latency
The most critical performance metric for an editor is input latency — the time from keypress to the character appearing on screen. Target: < 16ms (one frame).
Keypress → beforeinput on hidden input proxy (~0ms)
↓
Create operation (~0.1ms)
↓
Apply to model (~0.1ms for a single character insert)
↓
Re-layout affected line + repaint dirty canvas region (~0.5-1ms)
↓
Repaint caret (~0.1ms)
↓
Browser composites the canvas (~1-2ms)
↓
Total: ~3ms — WELL within 16ms budget ✅
The collaboration layer (send to server, transform, receive) does NOT block the input pipeline. The operation is sent asynchronously after the local repaint completes. The accessibility mirror DOM is also updated off the critical path.
Large Document Optimization
| Technique | How it helps |
|---|---|
| Viewport-only painting | Paint only blocks intersecting the viewport + buffer; off-screen blocks are laid out lazily and never painted. Content is pixels, so there are zero off-screen DOM nodes to manage. |
| Dirty-region repaint | On a keystroke, clearRect + repaint only the affected line's rectangle, not the whole canvas. |
| Deferred formatting updates | When applying a format change to 100 paragraphs (select all → bold), batch the re-layout + repaint across requestAnimationFrame frames. |
| Incremental layout | After a paste of 10,000 characters, lay out + paint the first viewport immediately, then lay out remaining blocks in subsequent frames. |
| Debounced toolbar updates | Toolbar state (bold active, heading level) is derived from selection. Debounce derivation to 50ms — fast enough to feel instant, but avoids recomputing on every arrow key press in rapid navigation. |
| Layout / glyph-measure caches | Cache per-block layout geometry and text-measurement results (font metrics, glyph widths) in a WeakMap; invalidate only the blocks that changed. Avoids re-shaping unchanged text. |
| OffscreenCanvas / layer separation | Keep content on one canvas and the fast-changing caret/selection/remote-cursors on a lightweight overlay canvas, so caret blink and presence updates don't repaint content. |
Code Splitting Impact
| Chunk | Size (Gzipped) | Load trigger |
|---|---|---|
| Core editor + toolbar | ~250KB | Initial page load |
| Collaboration module | ~80KB | On WebSocket connect |
| Export (PDF/DOCX) | ~120KB | File → Export click |
| Comments sidebar | ~40KB | Open comments panel |
| Version history | ~50KB | Open version history |
| Image upload + resize | ~30KB | Insert → Image click |
15.4 Observability and Reliability
- Error Boundaries: Wrap the editor surface, toolbar, and comment sidebar in separate error boundaries. If comments crash, editing continues.
- Logging:
- Track keypress-to-screen latency for P95 monitoring.
- Track OT/CRDT operation round-trip time.
- Log WebSocket disconnects, reconnects, and their durations.
- Track "operation rejected" events (indicates client bugs).
- Performance monitoring:
- FCP, TTI, and LCP for document page.
- Editor first-interactive time (time from page load to first keystroke accepted).
- Frame rate during continuous typing (should be 60fps).
- Feature flags: Gate new editor features:
- New block types (toggleable sections, callout boxes).
- Alternative collaboration engines (OT vs CRDT A/B test).
- Graceful degradation:
- If WebSocket fails → switch to periodic HTTP polling (long-poll) for operations.
- If collaboration fails entirely → single-user mode with auto-save via REST.
- If custom rendering fails → fall back to a simplified DOM/
contentEditablerendering mode (the lighter-weight approach from §5.2.1) so the user can keep editing without the canvas engine.
16. Edge Cases and Tradeoffs
| Edge Case | Handling |
|---|---|
| Two users type at the exact same position | OT transform function deterministically orders inserts by user ID. Both characters appear, but in a consistent order across all clients. |
| User pastes 50,000 characters | Parse and insert as a single operation. Render in batches (first viewport immediately, rest deferred). Large pastes may cause a brief freeze — show a progress indicator for pastes > 5,000 chars. |
| User edits offline for 2 hours | All operations buffered in memory + IndexedDB. On reconnect, rebase buffered ops against server's current state. If the document has diverged significantly, warn the user and let them review the merge. |
| 100+ page document scrolling | Only the blocks intersecting the viewport (+ buffer) are painted to the canvas; the layout engine tracks total height so the scrollbar stays accurate. Off-screen blocks are laid out lazily and never painted. No off-screen DOM exists at all. |
| Collaborator's cursor is in a deleted paragraph | When User A deletes a paragraph that User B's cursor is in, map B's cursor to the start of the nearest surviving block. |
| Comment anchor on deleted text | Comments anchored to deleted text become "orphaned." Show them in the sidebar with a note: "The text this comment refers to was deleted." Allow the comment to be resolved or re-anchored. |
| Browser crash during editing | The last synced revision is on the server. Unsaved local operations (up to debounce interval) may be lost. IndexedDB buffer mitigates this for offline edits. |
| IME input during collaboration | While composing (e.g., Japanese IME), do not send intermediate states to the server. Only send the final committed text. Remote operations received during composition are queued and applied after composition ends. |
| Table with 1000s of cells | Virtualize table rendering (only cells in the viewport). Prohibit operations that would create tables larger than a limit (e.g., 500 cells). |
| Very large number of collaborators (50+) | Throttle presence broadcasts (every 100ms instead of every mouse move). Group cursors that are close together. Cap cursor rendering at 20 visible cursors. |
| Concurrent undo from different users | Each user's undo stack is independent. User A's undo only reverses A's operations. Remote operations transform the undo stack so it remains valid. |
Key Tradeoffs
| Decision | Tradeoff |
|---|---|
| OT over CRDT | Lower memory overhead, server-authoritative (good for permissions/versioning). But requires a central server and complex transform functions. CRDTs would be better for offline-first scenarios. |
| Custom document model over raw HTML | Clean data, collaboration-friendly, validatable. But requires building a full rendering pipeline and input handler from scratch. |
| Canvas rendering + parallel DOM (hybrid) | Pixel-perfect, browser-consistent rendering; full control over line breaking, pagination, and large-document performance; no contentEditable rendering quirks. But you must build your own layout/paint engine (line breaking, BiDi, ligatures, caret), a hidden input proxy for IME, and an offscreen mirror DOM for accessibility — a large investment. The lighter alternative (DOM + contentEditable) gets native IME/selection/spell-check for free but inherits browser inconsistencies. |
| CSR-only (no SSR) | Simpler architecture, static shell cached on CDN, no hydration/DOM-ownership conflicts with the custom editor engine, and content stays authoritative (fetched live). But slower first meaningful paint of content than SSR, and no SEO — acceptable since docs are auth-gated. |
| Viewport-only canvas painting | Handles 100+ page documents with stable performance and a tiny DOM. But adds complexity: you own scroll position management, total-height estimation for un-laid-out blocks, and dirty-region tracking. |
| WebSocket for collaboration | Low-latency bidirectional communication. But requires persistent connections (server resource cost) and reconnection handling. SSE would be simpler but only server→client. |
| Per-user undo | Correct behavior in collaborative context (undo YOUR changes, not others'). But complex to implement — undo stack must be transformed against every remote operation. |
| Debounced auto-save (server snapshots) | Reduces server write load. But periodic snapshots mean version history is coarser than operation-level history. Actual operation log is the true history; snapshots are for fast loading. |
17. Summary and Future Improvements
Key Architectural Decisions
- Custom document model — a typed tree of nodes with inline marks, independent of the DOM. Enables clean serialization, validation, collaboration, and cross-platform rendering.
- Operational Transformation (OT) for real-time collaboration — a central server assigns operation order; clients transform concurrent operations. Three-state client machine (synchronized, awaiting confirm, awaiting with buffer) ensures correct ordering.
- Hybrid canvas rendering — the document content is painted onto a
<canvas>from the model (pixel-perfect, browser-consistent, pagination-friendly), with a parallel DOM layer: a hidden input proxy for keystrokes/IME, native selection/clipboard, and an offscreen mirror DOM so screen readers can read the document. This matches modern Google Docs. - Viewport-only painting for large documents — only the blocks intersecting the viewport are painted; there are no off-screen DOM nodes regardless of document size.
- Per-user undo with stack transformation — each user's undo stack is independent and transformed against remote operations.
- Auto-save via operation stream — every operation is synced in real time via WebSocket; periodic snapshots provide fast document loading.
Possible Future Enhancements
- OffscreenCanvas + Web Worker painting: Move layout and canvas painting into a Web Worker via
OffscreenCanvas, keeping the main thread free for input and never dropping a frame during heavy repaints. (Building on the current canvas renderer.) - WebGL / WebGPU text rendering: GPU-accelerated glyph atlases for even faster painting of very large or graphically rich documents.
- CRDT migration: Migrate from OT to a CRDT-based system (like Yjs) for better offline support and peer-to-peer collaboration without a central server.
- AI-powered features: Real-time grammar suggestions, auto-complete sentences, summarize document, generate content from prompts.
- Branching and merging: Git-like document branches where users can make changes in a "branch" and merge them back into the main document with conflict resolution.
- Real-time voice/video: Embedded video chat within the document editing session for tighter collaboration.
- Plugin / extension system: Allow third-party developers to add custom block types, toolbar actions, and integrations (e.g., embed Figma frames, Jira issues).
- Cross-document linking: Reference and embed content from other documents with live updates.
- Advanced table operations: Spreadsheet-like formulas within tables, charts generated from table data.
- Web Worker for OT computation: Offload operation transformation and document model computation to a Web Worker, keeping the main thread free for rendering.
Endpoint Summary
| Endpoint | Method | Description |
|---|---|---|
/api/docs/:id |
GET | Fetch document content and metadata |
/api/docs/:id |
PUT | Save document snapshot |
/api/docs |
POST | Create new document |
/api/docs/:id |
DELETE | Delete document |
wss://collab.example.com/doc/:id |
WebSocket | Real-time sync and presence |
/api/docs/:id/comments |
GET / POST | Fetch or add comments |
/api/docs/:id/comments/:cid |
PUT | Edit or resolve comment |
/api/docs/:id/comments/:cid/replies |
POST | Add reply to comment thread |
/api/docs/:id/versions |
GET | List version history |
/api/docs/:id/versions/:vid |
GET | Fetch specific version |
/api/docs/:id/media |
POST | Upload media |
/api/docs/:id/export |
POST | Export to PDF/DOCX/HTML |
Complete Data Flow Summary
| Direction | Mechanism | Trigger | Target | Action |
|---|---|---|---|---|
| Initial Load | REST (CSR) | Page open |
GET /api/docs/:id → Model → Render |
Load and display document |
| User Types | Local (synchronous) | Keystroke | BeforeInput → Model → DOM | Instant local update |
| Sync to Server | WebSocket (async) | After local apply | OT Client → WebSocket → Server | Send operation for transformation |
| Receive Remote Edit | WebSocket (push) | Server broadcast | WebSocket → OT Transform → Model → DOM | Apply remote changes |
| Cursor Presence | WebSocket (throttled) | Cursor move | Presence → WebSocket → All Clients | Show remote cursors |
| Format Text | Local + Sync | Toolbar click or shortcut | Model operation → DOM → WebSocket | Apply and broadcast formatting |
| Add Comment | REST + RT | User comments |
POST /api/docs/:id/comments + WS broadcast |
Comment appears for all users |
| Auto Save | Implicit (OT stream) | Every operation | OT ops → Server → persist | Every edit is saved server-side |
| Version Restore | REST | User selects version |
GET /api/docs/:id/versions/:vid → Model → Render |
Reload document at version |
| Offline Edit | Local buffer | Network drops | Model → IndexedDB buffer → Sync on reconnect | Preserve and sync later |
More Details:
Get all articles related to system design
Hashtag: SystemDesignWithZeeshanAli
Git: https://github.com/ZeeshanAli-0704/front-end-system-design
Top comments (0)