Frontend System Design: How to Design Google Docs, Step by Step
"Design Google Docs" is one of the toughest frontend system design interview questions, and one of the most rewarding to get right. Typing text into a box sounds simple. But Google Docs lets many people type into the same document at the same time. Everyone sees everyone else's changes within moments, and nobody's work gets lost.
Behind that simple experience are some genuinely hard problems:
- How do you show each keystroke instantly, without waiting for the server?
- What happens when two people type in the same sentence at the same moment?
- How do you show where everyone else's cursor is?
- How do you keep a 300-page document smooth to scroll and type in?
- What happens when someone loses their internet connection halfway through a paragraph?
In this guide, we'll design the frontend of Google Docs from scratch, in plain language, in the same order you should follow in an interview:
- Understand what we're building (the requirements)
- Sketch the big picture (the architecture)
- Design the data (how we represent a document and its changes)
- Decide how the app talks to the server (the API)
- Make it fast, smooth and reliable (the optimizations)
For every decision, we'll explain why, not just what. Interviewers care far more about your reasoning than about a "correct" answer.
Want to practice first? Read the Design Google Docs problem and discussion areas and sketch your own answer before reading on.
A quick tour of Google Docs
You've almost certainly used it, but let's name the parts so we can refer to them later:
- The document is the page (or pages) of text in the middle. It can hold headings, paragraphs, lists, links, images and more.
- The toolbar at the top has buttons for bold, italic, headings, lists and so on.
- Collaborators appear as avatars at the top right. Inside the text, each person's cursor shows up as a colored line with their name next to it.
- Comments attach to a piece of highlighted text and appear in a sidebar on the right.
- The save status, a small message like "Saving..." or "All changes saved", tells you whether your work is safe.
- Version history lets you look back at earlier versions and restore one.
┌──────────────────────────────────────────────────────────────┐
│ Project Plan All changes saved (A) (R) Share │
│ [B] [I] [U] Heading 1 Bullets Link Comment │
├────────────────────────────────────────┬─────────────────────┤
│ │ │
│ Goals for Q3 │ Ravi: │
│ │ Can we add the │
│ We will launch the new editor │ launch date here? │
│ in July|Asha and grow weekly │ │
│ active users by 20%.|Ravi │ Reply Resolve │
│ │ │
└────────────────────────────────────────┴─────────────────────┘
Here, (A) and (R) are Asha's and Ravi's avatars, and |Asha marks where Asha's cursor is right now.
Step 1: Understand what we're building
The most common mistake in a system design interview is jumping straight into components and code. Interviewers keep the question vague on purpose. They want to see whether you clarify the problem first, the way you would before starting a real project at work.
Spend the first five minutes agreeing on what you're building.
Ask questions that change the design
Ask the questions whose answers would change what you build. Here are five good ones, with the answers we'll assume for the rest of this guide:
- Can several people edit the same document at the same time? This is the heart of the problem. We'll assume yes. Google Docs lets up to 100 people with view, edit or comment access work on a file at once, so let's design for dozens of people editing together.
- How long can a document get? A one-page note and a 300-page book need different rendering strategies. We'll assume documents can run to hundreds of pages. (Google Docs allows up to 1.02 million characters.)
- Which formatting do we need? Headings, bold, italic, underline, lists, links and images. We'll leave out tables and drawings to keep the scope manageable.
- Should it work offline? Yes, as a nice-to-have: people should be able to keep typing on a plane and sync later.
- Do we need comments and version history? Comments, yes. Version history, in a simple form.
What the app must do
These are the functional requirements: the features a user can actually use.
- Create a document and give it a title.
- Type and edit text, and format it with headings, bold, italic, underline, lists, links and images.
- See other people's edits appear live, as they type.
- See where other people's cursors and selections are, with their names.
- Add comments to a piece of text, and reply to or resolve them.
- Look at earlier versions of the document and restore one.
How well it must do it
These are the non-functional requirements: the qualities that decide how the app feels. For an editor, they matter even more than the features.
- Typing feels instant. A character must appear the moment you press the key, whatever the network is doing. Even a small delay makes an editor feel broken.
- Everyone ends up with the same document. However edits overlap, once everyone has received all the changes, every person must see exactly the same text. Engineers call this convergence.
- No lost work. Every keystroke must eventually be saved, even if the connection drops. And the user should always know whether their work is saved.
- Smooth with long documents. Scrolling and typing should stay smooth in a 300-page document.
- Works offline (nice to have), and catches up when the connection returns.
- Accessible. People who use a screen reader or only a keyboard must be able to read, write and move around the document.
What we're leaving out
For this design, we'll skip sharing and permissions screens, sign-in, tables and drawings, suggestion mode, spell check, printing, exporting to PDF or Word, and add-ons. Naming them shows you know they exist, while keeping the interview focused.
Interview tip: The interesting part of this question is real-time collaboration, so say that early: "The core challenge is letting many people edit the same text at once without losing anyone's changes, so I'd like to spend most of our time there." Interviewers love candidates who find the hard part quickly.
Step 2: Sketch the big picture
Now that we know what we're building, let's decide how the app is shaped: how it renders, what components it's made of, and how a keystroke travels through it.
A single-page app
Google Docs is a single-page application (SPA). The browser downloads the app's JavaScript once, and from then on the app updates the screen itself, without reloading the page.
Documents are private and sit behind a login, so search engines never see them, and the app is extremely interactive. That makes client-side rendering the natural choice. We'll still make the first load fast with caching and code splitting, covered in Step 5.
The editor is the hardest component
Most of the page is ordinary UI: a header, a toolbar, a sidebar. The editor in the middle is where the real difficulty lives. There are three ways to build one:
- Plain
contenteditable. Browsers can make any element editable with a single attribute. It's easy to start with, but browsers don't agree on what to do. Pressing Enter might create a<div>, a<p>or a<br>depending on the browser, and you get very little control. Building collaboration on top of HTML that changes unpredictably is a nightmare. - Your own document model, with
contenteditableas the view. The editor keeps its own clean description of the document (paragraphs, text and formatting), and that description is the source of truth. Keystrokes are captured, turned into changes to the model, and the screen is redrawn from the model. Popular open-source editors such as ProseMirror, Tiptap, Lexical and Slate work this way. - Draw everything yourself on a
<canvas>. You control every pixel of layout, which can be very fast. But you have to rebuild things the browser normally gives you for free: text selection, the blinking cursor, spell check, typing in languages that need an input method (like Chinese or Japanese), and screen reader support. In 2021, Google announced it was moving Docs from HTML-based rendering to canvas-based rendering, to make it faster and to display content consistently across platforms.
In an interview, option 2 is the best answer. You get a predictable model to build collaboration on, while the browser still handles text input, selection and accessibility. Mention option 3 as what Google does at its scale, and explain what it costs.
The component tree
Here's how the screen breaks down into components:
DocPage the whole page, at /document/:docId
├── DocHeader title, save status, collaborator avatars
├── Toolbar bold, italic, headings, lists, links
├── EditorArea
│ ├── Editor the editable document (model -> DOM)
│ │ └── Block one paragraph, heading, list item or image
│ └── RemoteCursors other people's cursors and selections
├── CommentsSidebar comment threads, next to their text
└── VersionHistoryPanel earlier versions (loaded only when opened)
Here's what each one does:
- DocPage owns the whole page. It loads the document, opens the live connection to the server, and holds everything together.
- DocHeader shows the title, the save status and the avatars of everyone who has the document open.
- Toolbar shows the formatting buttons. It never touches the page directly. Instead, it asks the editor to run commands like "make the selection bold". It also highlights the buttons that match the current selection, so the B button looks pressed when your cursor is in bold text.
- Editor turns the document model into what you see on screen, and turns your keystrokes into changes to the model.
- Block renders one paragraph, heading, list item or image. Splitting the document into blocks means that typing in one paragraph only redraws that paragraph.
- RemoteCursors is a transparent layer on top of the text. It draws other people's cursors and highlighted selections in their own colors.
- CommentsSidebar shows comment threads next to the text they belong to.
- VersionHistoryPanel is opened rarely, so its code is downloaded only when someone opens it.
The four layers
Underneath the components, the app has four layers. Think of a group of people editing one shared document with a referee:
- The UI is what people see and touch: the toolbar, the editor surface and the remote cursors.
- The document model is each person's own copy of the document, plus where their cursor is. The UI draws from it.
- The collaboration engine is each person's assistant. It turns edits into small, precise change notes called operations ("insert 's' at position 42"). It keeps a pending list of operations the server hasn't confirmed yet, and it adjusts incoming operations from other people so they fit this copy of the document.
- The server is the referee. It decides the one official order of all operations, stores them in a log, and forwards each one to everybody else.
┌───────────────────────────────┐
│ UI: toolbar, editor, cursors │
└─────┬───────────────────^─────┘
your edits │ │ what to draw
┌─────v───────────────────┴─────┐
│ Document model + selection │
└─────┬───────────────────^─────┘
your operations │ │ others' operations
┌─────v───────────────────┴─────┐
│ Collaboration engine │
│ pending list · transform │
└─────┬───────────────────^─────┘
bundles │ │ acks + others' operations
┌─────v───────────────────┴─────┐
│ Server: official order + log │
└───────────────────────────────┘
The app also keeps a copy of the document and the pending list in the browser's own storage (IndexedDB), so it opens fast and survives going offline. We'll come back to that in Step 5.
Following one keystroke from start to finish
Asha and Ravi both have "Project Plan" open. Asha types the letter "s" to turn "goal" into "goals":
- Asha's screen updates immediately. The editor turns the keystroke into a tiny operation, "insert 's' at position 42", and applies it to her document model at once. The "s" appears instantly.
- The operation joins the pending list. If nothing is currently waiting for the server, the collaboration engine sends it right away. If something is, it bundles the "s" together with her next keystrokes.
- The server puts it in order. The server checks which version of the document Asha was looking at when she typed. If other changes arrived in the meantime, it adjusts her operation to fit, then saves it as the next numbered revision, say revision 108.
- The server tells everyone. It sends Asha a short acknowledgment ("your change is revision 108") and forwards the operation to Ravi.
- Ravi's screen updates. Ravi's app adjusts the operation for anything he has typed that the server hasn't seen yet, then applies it. The "s" appears on his screen, in exactly the right place.
- Asha's app moves on. When the acknowledgment arrives, her app removes the operation from the pending list and sends the next bundle, if there is one.
All of this takes a fraction of a second. The key idea lives in steps 3 and 5: adjusting operations so they still make sense after other people's changes. That's the heart of this design, and we'll unpack it in Step 3.
Step 3: Design the data
Good data design makes everything else easier. Let's decide how to represent a document, how to represent a change, and how to keep everyone's copy identical.
How to represent a document
The tempting answer is "store the HTML". But HTML is a poor source of truth for an editor. Text and tags are mixed together, so "position 42" doesn't clearly mean anything. Different browsers produce different HTML for the same edit. And comparing or merging two versions of an HTML string is very hard.
Instead, we describe the document as a list of blocks. Each block knows what it is (a paragraph, a heading, a list item or an image) and holds text runs: stretches of text that share the same formatting.
{
"id": "doc_42",
"title": "Project Plan",
"revision": 107,
"blocks": [
{ "id": "b1", "type": "heading", "level": 1, "text": [{ "text": "Goals for Q3" }] },
{
"id": "b2",
"type": "paragraph",
"text": [
{ "text": "We will launch the " }
Here are the same shapes written as TypeScript types:
type Doc = {
id: string;
title: string;
revision: number; // how many changes the server has accepted so far
blocks: Block[];
};
type Block =
| { id: string; type: "paragraph"; text: TextRun[] }
| { id: string; type: "heading"; level: 1 | 2 | 3; text: TextRun[] }
| { id: string; type: "listItem"; style: "bullet" | "number"; text: TextRun[] }
| { id: string; type: "image"; src: string; alt: string };
type TextRun = {
text: string;
marks?: ("bold" | "italic" | "underline")[];
?: ;
};
For collaboration, it also helps to think of the whole document as one long line of characters, numbered 0, 1, 2 and so on, where the start and end of each block count as positions too. That gives every spot in the document a simple number, and changes can say exactly where they happen.
Decision 1: The model is the truth, the screen is just a view
The editor never reads the document back out of the page. When you type, it updates the model first, then redraws the part of the screen that changed. This has big benefits:
- Predictable. The model looks the same in every browser, even if their HTML wouldn't.
- Easy to send. A model can be turned into small, precise changes and sent over the network.
- Easy to undo. Every change to the model is recorded, so undo and redo are straightforward.
- Collaboration-ready. Other people's changes are applied to the model too, and the screen simply follows.
Decision 2: Save changes, not whole documents
Imagine saving the entire document every time someone presses a key. A 100-page document would be re-uploaded hundreds of times a minute, and two people saving at once would overwrite each other's work.
Instead, every edit becomes a small operation. Three kinds cover almost everything:
type Operation =
| { type: "insert"; pos: number; text: string; marks?: string[] }
| { type: "delete"; pos: number; length: number }
| { type: "format"; pos: number; length: number; marks: Record<string, boolean> };
Typing "s" becomes { type: "insert", pos: 42, text: "s" }. Selecting a word and pressing Ctrl+B becomes a format operation. This approach has three big advantages:
- Tiny. A keystroke is sent as a few bytes, not as the whole document.
- Mergeable. Small, precise changes can be combined with other people's changes, as we'll see next.
- History for free. The server keeps every operation in order, in a log. That log is the document's history: replay it from the start and you can rebuild the document as it was at any moment. Google described storing Docs exactly this way, as a log of changes that is replayed to show the document.
Replaying a million operations every time someone opens a document would be slow, so the server also saves a full snapshot every few hundred operations. To open a document, the app loads the latest snapshot and replays only the operations that came after it.
Decision 3: What happens when two people edit at once
This is the most important part of the whole design, and the part interviewers will push on hardest.
The problem. Say the document contains the word "CART". At the same moment:
- Asha deletes the "R" (position 2), to make "CAT".
- Ravi inserts an "H" at position 1, to make "CHART".
Each app applies its own change immediately, then receives the other person's change a moment later:
Asha's screen Ravi's screen
Start CART CART
Own change delete @2 -> CAT insert "H" @1 -> CHART
Other's change insert "H" @1 -> CHAT delete @2 -> CHRT (wrong!)
Asha ends up with "CHAT", which is what both of them intended. But Ravi ends up with "CHRT". What went wrong?
C A R T C H A R T
0 1 2 3 0 1 2 3 4
When Asha deleted position 2, it meant the "R". But on Ravi's screen, his new "H" pushed every later letter one step to the right. Position 2 is now the "A". Applying Asha's operation exactly as she sent it deletes the wrong letter. Now their documents disagree, and they'll stay that way forever.
Simply letting the last change win doesn't work either. For a short field like a title, that might be acceptable. In a text editor it would silently throw away someone's typing.
There are two well-known solutions.
Solution 1: Operational Transformation (OT). Before applying someone else's operation, adjust ("transform") it to account for the changes it didn't know about. In our example, Ravi's app knows that his "H" went in at position 1, before Asha's delete reached him. So it shifts her delete from position 2 to position 3, and the right letter disappears:
Asha's screen Ravi's screen
Other's change insert "H" @1 -> CHAT delete @3 -> CHAT (fixed)
For operations that insert or delete a single character, the rules fit in a few lines:
// Adjust `op` so it still means the same thing after `applied` has happened.
// Both are single-character operations: { type: "insert" | "delete", pos, char?, clientId }
function transform(op, applied) {
if (applied.type === "insert") {
const insertedBefore =
applied.pos < op.pos ||
(applied.pos === op.pos && (op.type === "delete" || applied.clientId < op.clientId));
return insertedBefore ? { ...op, pos: op.pos + 1 } : op;
}
// `applied` is a delete
if (applied.pos < op.pos) return { ...op, pos: op.pos - 1 };
if (applied.pos === op.pos && op.type === "delete") return null; // already deleted
return op;
}
In plain words:
- If someone inserted a character before your spot, your spot moves one step right.
- If someone deleted a character before your spot, your spot moves one step left.
- If you both deleted the same character, your delete has nothing left to do.
- If you both inserted at the exact same spot, both apps need to agree whose character comes first, so we use a fixed rule: compare the two user ids.
Real OT has to handle much more than this: inserting whole words, deleting ranges, formatting, and every combination of them. That's why teams rely on proven implementations rather than writing their own from scratch. But this snippet captures exactly the idea an interviewer wants to hear.
The server keeps the official order. In an OT system, the server numbers every operation it accepts: revision 1, 2, 3 and so on. Each operation a client sends says which revision it was based on: "I made this change while looking at revision 107." If the server has already reached revision 109, it transforms the operation against revisions 108 and 109, then saves it as revision 110. Clients do the same thing in reverse: when a teammate's operation arrives, they transform it against their own operations that the server hasn't confirmed yet.
To keep this manageable, each client sends only one change at a time. While it waits for the server's acknowledgment, new keystrokes collect into the next bundle. This is how Google described its own system when it launched the new Google Docs in 2010: each editor applies its own changes immediately, never sends the server more than one change at a time, and transforms incoming changes against its own unconfirmed ones.
Solution 2: CRDTs (Conflict-free Replicated Data Types). Instead of fixing up positions after the fact, CRDTs avoid positions altogether. Every character gets a permanent, unique id, such as "Ravi-17" (built from the author's id and a counter). An insert doesn't say "at position 1". It says "right after the character Asha-3". A delete doesn't remove the character; it marks it as deleted (a "tombstone"), so any operation that mentions it still makes sense. Because operations point at ids that never change, they can be applied in any order, and every copy still ends up identical. No central server is needed to decide the order.
The trade-off is memory. Every character carries an id, and deleted characters stick around as tombstones. Modern CRDT libraries such as Yjs and Automerge compress this data very effectively.
Which should you pick? Either is a strong answer if you explain why:
- OT fits naturally when there's a central server that sees every change anyway. The document stays compact, and it's the approach Google described building Docs on.
- CRDTs shine when people stay offline for long stretches or connect directly to each other, because changes merge without anyone deciding the order. Libraries like Yjs plug straight into editors such as ProseMirror, Tiptap and Lexical.
A solid interview answer sounds like: "Since we have a central server anyway, OT is a proven fit, and it's what Google built Docs on. If long offline sessions were a top priority, I'd consider a CRDT library like Yjs instead."
Presence: cursors are not part of the document
Other people's cursors and selections are called presence. They change constantly, they aren't part of the document, and nobody needs them in the history. So we keep them completely separate:
type Presence = {
userId: string;
name: string;
color: string; // each person gets their own cursor color
selection: { anchor: number; head: number }; // where the selection starts and ends
lastActiveAt: number; // used to fade out idle cursors
};
When the text changes, cursors have to move too. If Ravi's cursor is at position 50 and Asha inserts three characters at position 10, Ravi's cursor must slide to position 53. The same transform logic that adjusts operations adjusts cursors.
Comments: pinned to a range of text
A comment points at a stretch of text, say positions 120 to 145. As people edit, that range has to move and stretch with the text, just like a cursor does.
type CommentThread = {
id: string;
anchor: { from: number; to: number }; // adjusted as the text changes
quote: string; // the text that was highlighted, kept for reference
messages: { authorId: string; text: string; createdAt: string }[];
resolved: boolean;
};
If someone deletes all of the highlighted text, the comment loses its anchor. Instead of silently dropping it, keep the thread and show it with its saved quote, so the discussion isn't lost.
Versions
Version history comes almost for free from the operation log. A version is simply a revision number, optionally with a name like "Sent to client". To show revision 500, load the latest snapshot before it and replay the operations up to 500. To restore it, create new operations that turn today's document back into that version, so even a restore is just another change in the history.
Step 4: Decide how the app talks to the server
Next, let's define the API: the conversation between our frontend and the server.
Loading, catching up and comments
GET /docs/:id latest snapshot and its revision number
GET /docs/:id/ops?since=107 every operation after revision 107
GET /docs/:id/versions list of saved versions
GET /docs/:id/versions/:rev the document as it was at that revision
GET /docs/:id/comments all comment threads
POST /docs/:id/comments start a thread { anchor, quote, text }
POST /comments/:id/replies reply to it { text }
PATCH /comments/:id resolve it { resolved: true }
- Opening a document is one request.
GET /docs/:idreturns the latest snapshot plus its revision number, so the editor can show the document straight away. - Catching up is a separate request. After a dropped connection, the app asks for every operation since the last revision it knows about and applies them, instead of downloading the whole document again.
Live editing
Editing needs a fast, two-way connection, because changes flow constantly in both directions. A WebSocket stays open, so the server can push teammates' operations the moment they arrive. In the messages below, → is sent by the browser and ← comes from the server:
→ { type: "ops", batchId: "a1b2", baseRevision: 107,
ops: [{ type: "insert", pos: 42, text: "s" }] }
← { type: "ack", batchId: "a1b2", revision: 108 }
← { type: "ops", revision: 109, userId: "ravi",
ops: [{ type: "delete", pos: 10, length: 3 }] }
→ { type: "presence", selection: { anchor: 43, head: 43 } }
← { type: "presence", userId: "ravi", name: "Ravi", color: "#e8710a",
selection: { anchor: 7, head: 12 } }
baseRevisiontells the server which version of the document the operations were made on, so it knows what to transform them against.batchIdis a unique id for each bundle. If the connection drops and the app sends the same bundle again, the server recognizes the id and doesn't apply it twice.- The
ackconfirms the bundle and gives it its official revision number. Only then does the app send its next bundle. - Presence messages are fire-and-forget. They're never saved, and if one gets lost, the next one replaces it anyway.
How the pieces talk inside the app
The toolbar, keyboard shortcuts and menus never edit the page directly. They call editor commands:
editor.commands.toggleBold();
editor.commands.setHeading(2);
editor.commands.insertImage({ src, alt });
Each command turns into operations on the model, so it travels the same path as typing: applied locally, sent to the server, and merged with everyone else's edits. In the other direction, the editor announces selection changes, so the toolbar can show which formatting is active.
Step 5: Make it fast, smooth and reliable
The basic design works. This next part is what separates a good answer from a great one. In an interview, spend most of your remaining time here.
Instant typing: never wait for the network
The golden rule of a collaborative editor: apply your own changes immediately, and sync in the background. The pending list makes this work:
- What you see is the confirmed document plus your pending operations. You never wait for the server to see your own typing.
- Bundle keystrokes. Since only one bundle is in flight at a time, keystrokes typed while waiting collect into the next bundle. Fast typists automatically send fewer, bigger bundles instead of flooding the server.
- Merge tiny operations. Typing "hello" can travel as one operation,
insert "hello" at 42, instead of five separate inserts. - Handle rejections gracefully. The server rarely rejects edits, but it can, for example if the owner removes your edit access. When that happens, switch to view-only mode, say so clearly, and keep the user's unsaved text visible so they can copy it somewhere else instead of losing it silently.
Show the save status honestly
People only trust an editor if they know their work is safe. The pending list tells us exactly what to show:
- "Saving..." while there are pending operations.
- "All changes saved" when the pending list is empty.
- "Offline. Changes will sync when you reconnect" when the connection is down.
If the user tries to close the tab while changes are still pending, warn them with the browser's beforeunload event.
Other people's cursors
- Throttle cursor updates. Your cursor moves with every keystroke and every mouse drag. Sending every movement would be wasteful, so send updates at most a few times per second (for example, every 50 to 100 milliseconds), always with the latest position.
- Keep cursors in the right place. When text changes, adjust remote cursor positions just like operations, so Ravi's cursor stays next to the word he's editing.
- Fade idle cursors. Hide the name label after a few seconds without activity, and remove people who disconnect.
- Don't crowd the screen. With dozens of people in a document, show a handful of avatars and a "+12" bubble for the rest.
Long documents
A 300-page document can contain tens of thousands of paragraphs. Redrawing all of them on every keystroke would make typing sluggish.
- Redraw only the changed block. Each block is its own component with a stable id as its
key. Typing in one paragraph redraws only that paragraph. - Skip work for pages you can't see. The CSS property
content-visibility: autotells the browser to skip layout and painting for elements that are off-screen, while keeping their text in the page. Browser search (Ctrl+F) and screen readers still work. It's supported in all major browser engines: Chrome, Edge, Firefox and Safari. - Work out page breaks bit by bit. Deciding where each page ends means measuring text, which is expensive. When you edit page 40, only page 40 onwards can change, so recalculate from there, in small chunks while the browser is idle, rather than in the middle of typing.
- Delay what isn't urgent. The word count and the document outline don't need to update on every keystroke. Debounce them, so they update after a short pause in typing.
Why not fully virtualize the document, like a long list, and remove off-screen paragraphs from the page? In an editor, that breaks things the browser does for you: Ctrl+F search, selecting text across pages, and screen readers. content-visibility gives most of the speed benefit without those problems. Google's move to canvas rendering takes the other route: it gives up the browser's built-in text features in exchange for full control over drawing.
Offline support
- Keep a local copy. Store the latest snapshot and the pending list in IndexedDB. Reopening the document, even without a connection, shows the local copy instantly.
- Keep typing offline. New operations pile up in the pending list. Because the list is saved in IndexedDB, it survives a closed tab or a restart.
- Catch up on reconnect. When the connection returns, first fetch the operations you missed, transform your pending operations against them, then send yours. With a CRDT, the two sets of changes simply merge.
- Load the app itself offline. A service worker caches the app's code, so the editor opens even with no network at all.
Set expectations, too. If two people heavily rewrite the same paragraph while one of them is offline for hours, nobody's characters are lost, but the merged sentence may read oddly. These tools merge characters, not meaning. That's normal, and version history is the safety net.
Undo and redo with other people
In a shared document, undo must only undo your own changes. Pressing Ctrl+Z should never delete the paragraph Ravi just wrote.
- Keep an undo stack that contains only your operations.
- To undo, create the opposite operation: an insert becomes a delete, and a delete becomes an insert.
- Before applying it, transform it against everything that happened since, including teammates' edits, so it still points at the right text.
- Group operations into sensible chunks, such as a burst of typing before a pause, so one undo doesn't remove a single letter at a time.
Uploading images
Don't make the user stare at a spinner while an image uploads. Insert it right away using a local preview (URL.createObjectURL(file)), upload it in the background with a progress bar, then swap in the real URL once the upload finishes. Collaborators see a placeholder until the image is ready.
Copy and paste
Pasting from websites or Word brings in messy HTML, full of styles, odd tags and sometimes scripts. Never insert it as-is. Parse it, keep only what your document model supports (headings, bold, links, images and so on), drop everything else, and turn the result into normal insert operations. This keeps the document clean and blocks malicious scripts.
Fast loading
- Show the document first. Draw the snapshot from IndexedDB (or the server) before anything else, then connect the WebSocket and catch up in the background.
- Split the code. The comments sidebar, version history, image tools and export features aren't needed to start reading or typing. Load them the first time they're used.
- Load fonts carefully. Documents can use many fonts. Load only the ones this document uses, and show text in a fallback font while they download (
font-display: swap), so the page is never blank.
Accessibility
- Real text for screen readers. With the
contenteditableapproach, the text is in the page, so screen readers can read it. A canvas-based editor must build a separate accessible layer, which is one of that approach's biggest costs. - Keyboard for everything. Every toolbar action needs a keyboard shortcut, and every menu must be reachable with the keyboard.
- Don't announce every remote keystroke. Reading out each character a teammate types would be overwhelming. Announce occasional summaries ("Ravi is editing") and important events, such as a new comment.
- Announce the save status politely. Put "All changes saved" in an
aria-live="polite"region, so screen reader users know their work is safe without being interrupted.
Security
- The server checks permissions on every operation. Hiding the toolbar from viewers isn't enough, because anyone can send messages over the WebSocket themselves. The server must reject edits from people who only have view or comment access.
- Sanitize pasted content and comments before showing them, to block cross-site scripting (XSS).
- Validate every operation. The server should reject operations that are malformed or point outside the document, so one buggy or malicious client can't corrupt the document for everyone.
How to present this in a 45-minute interview
You won't have time to say everything above in full, so plan your time:
- Requirements, about 5 minutes: collaboration, document size, formatting scope, offline, comments and versions.
- Big picture, about 10 minutes: the three ways to build an editor, the component tree, the four layers and the life of a keystroke.
- Data, about 10 minutes: blocks and text runs, operations, and the CART example with OT and CRDTs.
- API, about 5 minutes: snapshot and catch-up over REST; operations, acknowledgments and presence over the WebSocket.
- Deep dives, about 15 minutes: instant typing, cursors, long documents, offline and undo.
If time is short, go deep on three topics: the conflict example and how OT or CRDTs solve it, applying changes locally with a pending list and acknowledgments, and presence as separate, throttled data. These are where interviewers push hardest.
Mistakes to avoid
- Saving the whole document on every keystroke instead of sending small operations.
- Treating the page's HTML as the source of truth.
- Letting the last change win, which silently throws away people's typing.
- Waiting for the server before showing a typed character.
- Storing cursors inside the document or its history.
- Making undo remove other people's changes.
- Forgetting what happens when the connection drops.
Quick recap
- Requirements: dozens of people editing together, documents hundreds of pages long, rich text, comments, version history, and offline as a bonus. Typing must feel instant, everyone must end up with the same document, and no work may be lost.
- Big picture: a single-page app; an editor with its own document model, using
contenteditableas the view; four layers for UI, model, collaboration engine and server. - Data: blocks made of formatted text runs; every change is a small insert, delete or format operation; the server keeps an operation log plus regular snapshots; OT or CRDTs keep every copy identical; presence and comments live beside the document.
- API: REST for the snapshot, catching up, versions and comments; a WebSocket for operations, acknowledgments and presence, with
baseRevisionandbatchIdon every bundle. - Optimizations: local-first typing with one bundle in flight; an honest save status; throttled cursors that move with the text; per-block redraws,
content-visibilityand step-by-step page breaks; IndexedDB and a service worker for offline; undo that only touches your own changes; optimistic image uploads; cleaned-up paste; server-side permission checks.
Further reading
- What's different about the new Google Docs: Conflict resolution (Google, 2010)
- What's different about the new Google Docs: Making collaboration fast (Google, 2010)
- Google Docs canvas-based rendering update (Google Workspace Updates, 2021)
- content-visibility: the new CSS property that boosts your rendering performance (web.dev)
Practice the building blocks
Several pieces of this design come up as coding questions on their own: throttling cursor updates (a rate-limiting problem), retrying a failed send with exponential backoff, and transforming deeply nested objects like the document tree. You can practice these JavaScript patterns with a live playground and test cases on mentoxis.com.
Ready to put this into practice?
Book a 1:1 mock interview with a FAANG engineer, or work through free interview questions with a live code editor.