Design a writing assistant like Grammarly
Underline spelling, grammar and style mistakes as people type in any text field on any website, show a card with fixes, and apply a fix in one click. The hard parts are drawing underlines over editors you don't own, keeping suggestions attached to text that keeps changing, applying fixes so the host editor notices, and keeping private text private.
The question
Design the in-page part of a writing assistant like Grammarly. Wherever someone types on the web, in a Gmail reply, a LinkedIn post, a GitHub comment or a plain textarea, we underline mistakes as they type. Clicking an underline opens a card that explains the problem and offers fixes, and one click applies a fix.
Walk me through how you find and watch text fields you don't own, where the checking runs, how suggestions stay on the right words while the person keeps typing, how you draw the underlines, how a fix gets into the page so the site notices, and how you protect sensitive text.
Real-life examples: Grammarly browser extension and web editor, LanguageTool browser extension, Microsoft Editor in Edge and Word, Spelling and grammar suggestions in Google Docs, The browser's own red squiggles for spelling
Requirements: what to ask before drawing anything
A writing assistant reads what you type and points out mistakes: a misspelled word, a missing comma, a sentence that is hard to read. It draws a wavy or straight line under the problem. You click the line, a small suggestion card opens with an explanation and one or more fixes, and clicking a fix replaces the text.
The twist that makes this hard: the assistant runs inside text fields it does not own. The Gmail compose box belongs to Gmail, and the LinkedIn post editor belongs to LinkedIn, each with its own code and its own idea of what the text is. Our code is a guest that must read the text, draw on top of it and sometimes change it, without breaking the host.
Several neighbours have their own guides, linked below, so we don't repeat them. The browser extension guide covers content scripts, messaging with the background worker, permissions, and shadow DOM isolation for injected UI. The rich text editor guide covers document models, contenteditable and input events, and the code editor component guide covers IME composition.
The mention input guide covers finding the caret in a textarea, and the Medium blog guide covers anchoring highlights with quote and context selectors. Here we focus on suggestions inside text that someone else's editor owns.
Questions that change the design
- Where does it run? Only in our own web editor, or in every text field on every site through a browser extension? Our own editor is easy: we own the document model. Every site means we are a guest, and that is the version we design here. A script that sites embed on purpose is a third route, covered by the embeddable widget guide; Grammarly offered one as its Text Editor SDK and shut it down on January 10, 2024.
- What kinds of fields? Plain
textareaandinput, richcontenteditableeditors (Gmail, LinkedIn, Notion), and canvas-based editors like Google Docs each need a different way to read text and draw underlines. - What do we check? Spelling only can run on the device with a dictionary. Grammar, tone and rewrites need a language model, which today usually means a server.
- Can text leave the device? For a consumer product, usually yes with consent. For a company that bans sending email drafts to third parties, no. This single answer moves the checker from server to device.
- How fast must underlines appear? People expect a spelling underline within about a second of finishing a word. Slower than 2 seconds and the person has already moved on.
- Which languages? English only first, or many? Each language needs its own dictionary and rules, and right-to-left scripts change how underlines are drawn.
Functional requirements
- Find editable text fields on a page as they appear, including ones added later by a single-page app.
- Check the text and underline problems, with a colour and shape per category: spelling, grammar, clarity, tone.
- Keep underlines on the right words while the person types, scrolls, or resizes the window.
- Open a card on click or keyboard shortcut, with an explanation and 1 to 3 fixes.
- Apply a fix in one click so the host site sees the new text, and so
Ctrl+Zcan undo it. - Dismiss a suggestion, add a word to a personal dictionary, and turn the assistant off for a site.
- Never run in password fields, payment fields, or fields the site opts out of.
Non-functional requirements, with numbers
| Need | Target | Why this number |
|---|---|---|
| Typing latency added | 0 ms visible, under 4 ms of main-thread work per keystroke | At 60 frames per second a frame is about 16.7 ms. The host editor needs most of it. If we steal it, typing feels sticky and people blame the site. |
| Spelling underline | Under 300 ms after a pause, on the device | A dictionary lookup takes microseconds. The wait is our debounce, not the check. |
| Grammar underline | Under 1.5 s after a pause (estimate) | Debounce of about 500 ms plus a server round trip of 300 to 800 ms. |
| Text size | Up to about 50,000 characters per field (estimate) | A long Google Doc or a big Notion page. Re-checking everything on each pause would send megabytes per hour. |
| Underlines on screen | Up to a few hundred | A rough first draft can have 1 problem every 20 words. A 3,000-word draft has 150. |
| Alignment | Within 1 to 2 pixels of the text | An underline under the wrong word is worse than no underline. |
Out of scope
- Training the language model and the grammar rules themselves: a backend and research problem.
- Extension packaging, store review and background-to-content messaging: see the browser extension guide.
- Full AI rewrite chat with streaming answers: see the LLM chat UI guide. We only show short fixes.
- Native desktop apps like Word or Slack desktop: they need operating system integration, not web APIs.
What makes this hard
A spell checker in your own app is a weekend project: keep the text in state, run a check, render the words with a class. Every difficulty in this problem comes from four facts about being a guest in someone else's editor.
- We don't own the text. The host editor owns the document model. In a
textareathe text is a single string with no DOM inside it. In acontenteditableeditor like Gmail's, the DOM is a view of the host's own model, and the host may rebuild it at any moment. In Google Docs there is no text DOM at all. - The text never stops changing. A server check takes about half a second. In that time a fast typist adds 3 to 5 characters. Every suggestion is a claim about a specific version of the text, and that version is already old when the answer arrives.
- We can't touch the host's DOM. If we wrap a misspelled word in a
spaninside Gmail's editor, Gmail's code sees an element it didn't create. It may delete it, copy it into the sent email, or crash its own selection logic. So underlines live in a separate layer on top, and that layer must line up with text we can't style. - The text is private. People type passwords, medical notes, contracts and love letters. Anything we read could be sent to a server, logged or leaked. The safest text is text we never read.
These four facts drive the rest of the guide. Fact 1 gives us field adapters, one per kind of field, and fact 2 gives us versioned suggestions that are mapped through edits. Fact 3 gives us an overlay and a careful way to apply fixes through the host's own input path. Fact 4 gives us a privacy gate that runs before anything else.
There is also a budget problem. The extension runs on every page the person visits, often in dozens of tabs, and competes with the host for the main thread on every keystroke. Reading positions right after a DOM change forces the browser to recalculate layout (see the reflow, repaint and composite lesson), which can cost 10 to 30 ms on a long document. Done on every keystroke, that makes every site feel slow.
Check your understanding · Exercise 1
Which answers from the interviewer would change the architecture the most? Select all that apply.
Reading text from fields you don't own
Before we can check anything we have to find the fields and read their text. The browser extension guide covers how the content script finds fields as they appear: a MutationObserver on the document plus a focusin listener.
The new part here is that each kind of field hides its text differently. Each one needs its own answer to three questions: how do we read the text, how do we find where a character is on screen, and how do we change the text?
| Field kind | Read the text | Find a character on screen | Change the text |
|---|---|---|---|
textarea, input | el.value: one plain string | Not possible directly. The text has no DOM nodes, so there is no Range to measure. We need a mirror element. | Native value setter plus an input event, or execCommand after setting the selection |
contenteditable (Gmail, LinkedIn, GitHub) | Walk the text nodes inside the element | Range.getClientRects() on a range over the text nodes | Select the range, then execCommand('insertText'), so the host sees a real edit |
| Rich editor frameworks (ProseMirror, Lexical, Slate, Draft.js) | Same as contenteditable, but the DOM is a rendering of the host's model | Same as contenteditable | Same, and never edit the DOM directly: the framework will undo it or crash |
| Canvas editors (Google Docs) | No text in the DOM at all | No DOM rects | Only through a dedicated integration with the host |
| Inside shadow roots or iframes | Same as above, once we can reach the element | Rects are relative to that frame's viewport | Same as above, inside that frame |
textarea and drawing is hard. In contenteditable both work, but the DOM belongs to the host. On a canvas, neither works without the host's help.Plain fields: text without a DOM
A textarea is the easy case for reading and the hard case for drawing. el.value gives the whole text, and selectionStart and selectionEnd give the caret as character offsets. But the browser draws the text inside the control, and no API returns the pixel position of character 412.
document.createRange() can only point at DOM nodes, and the text inside a textarea is the control's value, not nodes we can select. The mention input guide met the same problem for placing a popup at the caret. We need it for hundreds of words at once, which is the subject of the overlay chapter.
Rich fields: text spread across many nodes
In a contenteditable editor the text is spread across text nodes inside paragraphs, links, bold spans and list items. We walk the tree with a TreeWalker that accepts text nodes, and build two things. The first is the flat text: one string, with a newline between block elements. The second is an offset map from each flat offset back to a text node and an offset inside it.
The checker only ever sees flat text. When it says "characters 120 to 128", the offset map turns that into a DOM Range, and range.getClientRects() returns one rectangle per line box the word covers.
Some nodes must be skipped: a quoted reply in Gmail, a code block, a mention chip, an image's alt text. We skip elements with contenteditable="false", code and pre, and anything the site marks as not to check. Skipped parts become a placeholder character in the flat text so offsets after them still line up.
Canvas editors: Google Docs
In May 2021 Google announced that Docs would move from HTML rendering to canvas-based rendering, and warned that some Chrome extensions might stop working. It pointed integrations to the Google Workspace Add-ons framework instead. With a canvas there are no text nodes to read and no rects to measure.
Some writing assistants still underline text in Docs, which points to host-specific cooperation rather than the generic approach. The public details are thin, so in an interview say so. A canvas editor needs a host-specific integration or an official add-on API, and our design treats it as its own adapter with its own release schedule.
Decision How should the extension handle so many kinds of fields?
What we pick: Field adapters. The interface is small, and it isolates the one thing that truly differs between fields: how text maps to the screen and back. Two generic adapters (plain and contenteditable) cover most of the web; a short list of site adapters covers the editors people use most, and each can be switched off remotely when a site redesign breaks it.
Shadow DOM and iframes
Two places hide fields from a plain document.querySelectorAll. Fields inside an open shadow root are reachable through element.shadowRoot, so the scanner must walk into shadow roots. A focusin listener on the document sees the shadow host as the target, so we read event.composedPath()[0] to get the real field. Closed shadow roots are out of reach by design, and we accept that.
Fields inside iframes (many email editors use one) need the content script to run in that frame too, which the browser extension guide covers with the all_frames setting. Each frame then runs its own small copy of the adapters and talks to the same background worker.
Check your understanding · Exercise 2
A misspelled word sits inside a textarea. Which statements are true? Select all that apply.
Where the checking runs
A check takes text and returns matches: each one a position, a length, a category, a message and some replacements. LanguageTool's public API is a good picture of the shape: you POST text to /v2/check and get back a matches array where each match has an offset, a length and replacements. Whatever model sits behind it, the frontend contract looks like this. The question is where that model runs.
The naive answer is "send everything to the server on every keystroke". Take one writer typing 200 characters a minute in a 5,000-character email: that is 200 requests and about 1 MB a minute for one person. LanguageTool's free public endpoint allows 20 requests and 75 KB of text per IP per minute, with at most 20 KB per request, so the naive client would be blocked within seconds. Paid services have higher limits, but the cost and the server load scale the same way.
Decision Where should spelling and grammar checks run?
What we pick: Hybrid. Spelling is cheap, private and latency-sensitive, so it belongs on the device. Grammar and tone need models too large to ship to every laptop today, so they go to the server, but only for paragraphs that changed and only when allowed. A strict privacy mode is the same design with the server path switched off.
Browsers are starting to offer on-device language models. Chrome ran an origin trial (an opt-in test that sites and extensions register for) of an experimental Proofreader API from Chrome 141 to 145, backed by its built-in Gemini Nano model on desktops with enough memory and disk space. It is not a stable, cross-browser API, so treat it as an optional backend for the device path, detected at runtime, never as a requirement.
What we send: paragraphs, not documents
Grammar needs a sentence of context, and some rules need the sentence before it. A whole document is too much: editing one word would re-send 50,000 characters. The middle ground is the paragraph: split the flat text at block boundaries (newlines in the flat text) and treat each paragraph as one unit of work.
A paragraph is often a few hundred characters: small enough to send often, large enough for grammar context. Very long paragraphs are split again at sentence ends with Intl.Segmenter set to granularity: "sentence", which handles abbreviations and many languages better than splitting on periods.
Each paragraph gets a hash of its text, for example a fast 64-bit hash computed in the worker. The client keeps a map from hash to the matches it got last time. When the person edits paragraph 7 of 40, only paragraph 7 has a new hash, so only it is sent. Moving a paragraph, or undoing back to an old version, hits the cache and costs nothing.
type Paragraph = { start: number; text: string; hash: string };
function paragraphs(flat: string): Paragraph[] {
const out: Paragraph[] = [];
let start = 0;
for (const part of flat.split(/(\n+)/)) {
if (part.trim() && !part.startsWith("\n")) {
out.push({ start, text: part, hash: hash64(part) });
}
start += part.length;
}
return out;
}
function needsCheck(paras: Paragraph[], cache: Map<string, Match[]>): Paragraph[] {
return paras.filter((p) => !cache.has(p.hash));
}
Matches from the server come back relative to the paragraph, so the client adds the paragraph's current start to turn them into document offsets. Storing matches per paragraph hash, not per document offset, is what lets the cache survive edits in other paragraphs: paragraph 7's matches are still right even if paragraph 2 grew by 40 characters.
Check your understanding · Exercise 3
A company bans sending email drafts to outside servers. Which parts of the hybrid design still work in strict privacy mode? Select all that apply.
Checking while the person types
Now the timing. A check that starts on every keystroke wastes work, because the next keystroke makes it stale, and one that waits for 3 seconds of silence feels broken. The standard tool is a debounce: restart a timer on every change and run the check only when the timer fires (see the debounce and throttle lesson). The design question is what to debounce, for how long, and what to do with checks already in flight when the text changes again.
| Check | Debounce (estimate) | Why |
|---|---|---|
| Spelling, on device | About 150 ms after a word boundary | Cheap to run. Waiting for a space or punctuation avoids flagging "recie" while the person is still typing "receive". |
| Grammar, server | About 500 ms after the last keystroke in that paragraph | Long enough that most bursts of typing finish, short enough that the underline lands while the person still looks at the sentence. |
| Whole field on focus | No debounce, at most once per field per page view | The first check of existing text, sent in paragraph batches so the first underlines arrive quickly. |
Two typing situations need extra care. During IME composition (typing Japanese, Chinese or Korean through an input method editor, covered in the code editor component guide) the text between compositionstart and compositionend is not final, so we pause checks and hide underlines in that paragraph until composition ends. And while the caret sits inside a word, we don't flag that word for spelling yet: the person is still writing it.
Versions: every check is about one moment of the text
Each field keeps a version number that goes up by 1 on every change the adapter sees. A check request records the paragraph's hash and the field version at send time. This is the request race problem from the request races lesson, with one twist: a late answer is not always useless, because most of it may still be correct.
When the answer arrives, there are three cases. If the paragraph's hash is unchanged, the matches apply as they are. If the paragraph changed, the matches are mapped forward through the edits since that version (the next chapter). If the paragraph was deleted, the answer is dropped.
Each paragraph moves through a small state machine. Clean means the matches we hold describe its current text. An edit makes it dirty and starts the debounce, and when the timer fires it becomes in flight. An error moves it to failed, which retries with backoff and shows nothing new, never an error toast in the middle of someone's email.
If the person types again while a check is in flight, the paragraph becomes stale. We keep the request running only if it is nearly done (the server has had it for over 300 ms, an estimate), because mapped results are better than nothing. Otherwise we abort it with AbortController. Either way the paragraph goes back to dirty, so the new text gets its own check.
429, the queue waits for Retry-After while on-device spelling carries on.Batching and rate limits
When a field first gains focus with existing text, there may be 40 paragraphs to check. Sending 40 requests at once hits rate limits and competes with the host page for the network. Instead the background worker keeps one check queue per tab, sends paragraphs in batches of a few KB, and gives priority to the paragraph with the caret and the ones on screen.
Paragraphs far below the fold wait until they scroll near the viewport. Across all tabs, the worker keeps a token bucket (a counter that refills at the server's allowed rate and is spent one token per request), so 12 open Gmail tabs can't add up to a ban.
When the server answers 429 Too Many Requests with a Retry-After header, the queue pauses for that long and the on-device spelling check carries on alone. The person sees fewer grammar underlines for a moment, not an error.
Check your understanding · Exercise 4
A grammar check for paragraph 3 is in flight. The person adds a word at the end of paragraph 3. What should happen to the response when it arrives? Pick one.
Keeping suggestions on the right words
A suggestion says "characters 52 to 58 of version 17 are recieve, replace with receive". By the time it is on screen, the text is at version 19, and the person may have typed before the word, inside it, or after it. If we keep drawing at 52 to 58, the underline slides onto the wrong letters. If we apply the fix at 52 to 58, we overwrite the wrong text: the worst bug a writing assistant can have, because it silently damages what the person wrote.
The Medium blog guide covers anchoring highlights to published text that changes between visits, with quote and context selectors: store the highlighted words plus a few characters before and after, and search for them later. Here the text changes many times a second while the suggestion is on screen, so we need something cheaper per keystroke, and we need to know exactly when a suggestion has become invalid.
Decision How do suggestions follow text that keeps changing?
What we pick: Map positions through each edit, and verify with the quote. Mapping handles the normal case of typing at a cost of a few comparisons per suggestion. Each suggestion also stores its expected text: before drawing or applying, we compare the text at the mapped range with it, and drop the suggestion if they differ. When we see a whole-text replacement we didn't expect, we fall back to re-finding by quote and context, once, then wait for the next check.
Getting edits from fields that only give us new text
A textarea fires input but doesn't say what changed. We keep the last value we saw and compare: the longest common prefix and the longest common suffix of old and new text leave one changed region in the middle. That region is the edit. For a 50,000-character value the scan takes well under a millisecond, and starting it near the caret makes it cheaper still.
For contenteditable, beforeinput events tell us the intended change, and getTargetRanges() gives the ranges it will affect. But the host may cancel it and make its own change instead. So we use the same prefix-and-suffix diff on the flat text after the change as the source of truth.
type Edit = { at: number; removed: number; inserted: number };
type Anchor = { start: number; end: number; expected: string };
function mapAnchor(a: Anchor, e: Edit): Anchor | null {
const editEnd = e.at + e.removed;
const delta = e.inserted - e.removed;
if (editEnd <= a.start) {
return { ...a, start: a.start + delta, end: a.end + delta };
}
if (e.at >= a.end) return a;
return null; // the edit touched the flagged text: drop it
}
function stillValid(a: Anchor, text: string): boolean {
return text.slice(a.start, a.end) === a.expected;
}
Look closely at the boundaries. An insertion exactly at a.start has editEnd equal to a.start, so the suggestion moves right: the person typed in front of the word. An insertion exactly at a.end leaves it unchanged: the person kept typing after the word, and the new letters are not part of the flagged text.
Letters are the exception. If someone types recieve and then d, the word is now recieved, and the old fix receive would be wrong. So on top of mapAnchor, one more rule: when a letter or digit lands right against either end of a flagged word, the suggestion is dropped and a new check runs. A space or punctuation mark next to the word is safe.
Late responses. A response for version 17 that arrives at version 19 is mapped through the edits from 17 to 19 before it is shown. That means the client keeps a short edit log: the last few hundred edits per field, or the last 10 seconds, whichever is shorter. A response older than the log is dropped. This is the same idea as an OT client rebasing a server operation over its local pending edits (see the operational transformation lesson), applied only to positions, which is the easy half.
Check your understanding · Exercise 5
teh is flagged at 10 to 13. Which edits keep the suggestion, possibly moved? Select all that apply.
Drawing underlines without touching the host
The obvious way to underline a word is to wrap it in a span with a class. In our own editor that's fine. In someone else's editor it fails in the ways the third hard fact listed: a textarea can't hold elements, and a ProseMirror or Lexical editor that sees a node it didn't create may delete it, fold it into the sent email, or lose track of the caret.
Even where nothing breaks, the span splits text nodes and confuses the host's own selection code. Rule one: we never write inside the host's editable element.
Decision How do we draw underlines over text we can't change?
What we pick: An overlay layer for every field, since it is the only option that covers plain fields too, with the Custom Highlight API as an optional painter for contenteditable fields in browsers that support the styles we need. Either way, the overlay owns hit testing and the card, and nothing we draw lives inside the host's editable element.
pointer-events: none every click still reaches the host field.The mirror element for plain fields
For a textarea, we build a mirror: an invisible div with the same box and text styles, holding the same text. Because it is a real element, the text inside it has nodes, so we can wrap flagged words in spans there (it's our element) and read their rects. Then we shift those rects by the field's position and scroll offset. Libraries such as textarea-caret-position use the same trick to find the caret.
The mirror must copy every style that changes where text wraps or sits. That means the font family, size, weight and style, letter-spacing, word-spacing, line-height, text-transform, text-indent, tab-size, padding and direction. It also needs the field's content width without the scrollbar, plus white-space: pre-wrap and overflow-wrap: break-word to match how a textarea wraps.
const COPY = [
"fontFamily", "fontSize", "fontWeight", "fontStyle", "letterSpacing",
"wordSpacing", "lineHeight", "textTransform", "textIndent", "tabSize",
"paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "direction",
] as const;
function syncMirror(field: HTMLTextAreaElement, mirror: HTMLDivElement) {
const cs = getComputedStyle(field);
for (const key of COPY) mirror.style[key] = cs[key];
mirror.style.whiteSpace = "pre-wrap";
mirror.style.overflowWrap = "break-word";
// clientWidth is padding plus content, without the border or the scrollbar
mirror.style.boxSizing = "border-box";
mirror.style.border = "0";
mirror.style.width = field.clientWidth + "px";
mirror.style.visibility = "hidden";
mirror.style.position = "absolute";
}
Rects measured in the mirror are relative to the mirror's top left, which lines up with the field's padding box. To place an underline over the real field we add the field's position and its border width (clientLeft and clientTop), then subtract field.scrollTop and field.scrollLeft. Underlines that end up outside the field's visible box are clipped by giving the overlay the same size as the field and overflow: hidden.
Staying aligned when anything moves
| What changes | How we notice | What we redo |
|---|---|---|
| The field scrolls | scroll listener on the field, passive | Only the scroll offset: a single transform on the overlay's inner layer, no re-measure |
| The page scrolls | Nothing, if the overlay is placed in the same scroll container as the field | Nothing. Placing it in document.body instead would need a re-position on every scroll |
| The field resizes or the window zooms | ResizeObserver on the field | Re-sync the mirror width and re-measure visible paragraphs |
| Fonts finish loading | document.fonts.ready and the loadingdone event | Re-measure everything: text widths changed |
| The host changes styles or classes | MutationObserver on the field's style and class attributes | Re-sync mirror styles, re-measure |
| The field moves on the page (layout shift) | Compare getBoundingClientRect() once per frame while the field is focused | Move the overlay; text inside it stays the same |
Performance. All measuring happens in one requestAnimationFrame callback: first every read (getComputedStyle, getClientRects, scrollTop), then every write (overlay positions). Mixing reads and writes forces the browser to lay out the page again for each read, which is the layout thrashing problem from the reflow, repaint and composite lesson.
With 300 suggestions on a long document, we measure only paragraphs inside or near the viewport, so a frame touches perhaps 20 ranges instead of 300. Underline boxes are positioned with transform, which can be composited without layout. Off-screen paragraphs keep their old rects, marked stale until they scroll in.
Check your understanding · Exercise 6
Underlines in a textarea line up on the first line but land on the wrong words further down. Which causes fit? Select all that apply.
Applying a fix so the host notices
The person clicks receive in the card. We need the text in the field to change, and three other things to happen with it: the host app must learn about the change (or its state stays on the old text and sends the typo anyway), Ctrl+Z must undo the fix like any typing, and the caret must stay where the person expects.
The naive way, field.value = newText, fails all three. Here's the trap: a React controlled input keeps the value in component state, and React tracks the last value it saw on the element. Assigning value directly updates that tracker too, so when we then dispatch an input event, React sees no change and doesn't call onChange. On the next render React writes its old state back, and the fix vanishes.
This behaviour has been discussed in the React issue tracker since 2017 (issue 10135). Assigning value also bypasses the browser's undo history, so Ctrl+Z can't undo the fix.
Decision How do we replace text inside a field we don't own?
What we pick: execCommand('insertText') first, for both plain and rich fields, because it is the only common path that looks exactly like typing to the host and keeps undo. Then verify: read the text back and check that the range now holds the fix. If it doesn't, plain fields fall back to the native setter plus input event (losing undo), and rich fields fall back to a site adapter or to showing the fix for the person to copy.
function applyPlain(field: HTMLTextAreaElement, a: Anchor, fix: string): boolean {
if (!stillValid(a, field.value)) return false;
field.focus({ preventScroll: true });
field.setSelectionRange(a.start, a.end);
const ok = document.execCommand("insertText", false, fix);
if (ok && field.value.slice(a.start, a.start + fix.length) === fix) return true;
// Fallback: no undo, but the host's state updates.
const next = field.value.slice(0, a.start) + fix + field.value.slice(a.end);
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype, "value")!.set!;
setter.call(field, next);
field.dispatchEvent(new Event("input", { bubbles: true }));
field.setSelectionRange(a.start + fix.length, a.start + fix.length);
return true;
}
insertText, read back, and the host's input event reaches the session. The failure path shown here costs undo history but still updates the host's state, and either way the session learns about the change only from the field.The caret and the rest of the suggestions
If the person clicks a fix in paragraph 2 while their caret was in paragraph 9, insertText leaves the caret after the fix in paragraph 2. That's jarring: they were writing in paragraph 9. So before applying we save the caret as offsets, apply the fix, map the saved caret through the fix as an edit (exactly like an anchor), and restore it.
Applying the fix is itself an edit, so every other suggestion is mapped through it too, and the fixed suggestion is removed. A fix that changes length, like alot to a lot, shifts every later suggestion by one character.
Some suggestions touch several places, for example a tone rewrite of a whole sentence. We apply them as one replacement of the smallest range that covers all changes, so undo removes the whole rewrite in one step rather than one word at a time.
Check your understanding · Exercise 7
A site's comment box is a React controlled textarea. Which ways of applying a fix leave React's state holding the fixed text? Select all that apply.
The suggestion card, keyboard and screen readers
The card is where people read and act on a suggestion. It opens when the pointer rests on an underline for about 300 ms, on click, or on a keyboard shortcut. It shows the category, a one-line explanation, 1 to 3 fixes as buttons, and secondary actions: dismiss, add to dictionary, and turn off for this site. It renders inside our own shadow root (the browser extension and embeddable widget guides cover that isolation), positioned next to the underline's rect and flipped above it near the bottom of the screen.
Hit testing. The overlay has pointer-events: none, so clicks go to the host editor and placing the caret still works. To know when the pointer is over an underline, we listen for pointermove on the field, throttled to one check per frame, and test the pointer against the underline rects we already have. A grid of rects bucketed by line makes this a handful of comparisons even with 300 underlines.
Don't steal focus. If clicking a fix button moved focus into the card, the field would lose focus, the host might close its compose box or fire validation, and execCommand would have no editable element to act on. Moving focus is the default action of mousedown, so the card's buttons call preventDefault() on mousedown (cancelling only pointerdown does not reliably stop it) and handle the action on click. The editor keeps focus, the caret stays where it was, and the fix lands.
Keyboard access
Mouse-only cards shut out keyboard users, who are often the fastest typists. We add one shortcut to move to the next suggestion after the caret, and one for the previous. The defaults must avoid shortcuts the host already uses, and people can change them.
Pressing the shortcut selects the flagged word in the field and opens the card in a keyboard mode. Focus moves into the card, arrow keys move between fixes, Enter applies and Escape closes, and in every case focus returns to the field with the caret where it should be (see the focus management and keyboard navigation lessons). This is the one time the card takes focus, because the person asked for it.
Screen readers
A screen reader user can't see underlines, and the overlay is hidden from assistive technology because it is decoration. Announcing every new underline would be unbearable: a sentence of typing could trigger five interruptions. Instead we keep one polite live region (an element whose changes the screen reader reads out, see the screen readers lesson) and announce a short count after a check settles, like "3 writing suggestions", at most once every few seconds and never while the person is typing.
The next-suggestion shortcut is the main path. It selects the word, so the screen reader reads it, and the card in keyboard mode is a labelled dialog whose explanation is read when it opens.
Not colour alone. Red for spelling and blue for grammar is meaningless to people with some colour vision differences. Each category also has a distinct shape: wavy for spelling, straight for grammar, dotted for style. In Windows high contrast and other forced colours modes, our colours are replaced, so underlines use system colours like CanvasText and Highlight under @media (forced-colors: active) and stay visible.
Check your understanding · Exercise 8
Which choices keep the card from breaking the person's typing? Select all that apply.
Privacy, opt-outs, and living with the host
A writing assistant reads everything you type. That makes privacy the first gate, not a setting buried in a menu. Before an adapter attaches to a field, a privacy gate asks a fixed list of questions, and any "no" means we never read that field's text.
- Field type. Never
type="password", and neveremail,tel,number, or one-time code fields. These are not prose, and checking them only creates risk. - Autocomplete hints. Fields with
autocompletevalues likecc-number,cc-csc,one-time-codeorcurrent-passwordhold secrets.autocomplete="off"alone is a weaker signal, since many sites set it on ordinary text boxes, so treat it as one input to a per-site default rather than a hard block. - Site opt-outs. Grammarly honours attributes such as
data-gramm="false",data-gramm_editor="false"anddata-enable-grammarly="false"on a field. Our extension should honour the same well-known attributes plus its own, and respectspellcheck="false"as a sign the site doesn't want text checked, for example in code editors. - Sensitive sites. A built-in list of categories like banking, health portals and password managers is off by default, and enterprise admins can push their own allow and block lists through managed browser policy.
- The person's choice. A per-site toggle in the card and in the toolbar, remembered in extension storage, and a global pause.
autocomplete="off" alone is a weak hint and only feeds the site default.Data handling. Text sent for grammar checks goes over HTTPS to our API only, never through the page's own network stack. The server should not keep it beyond what the check needs unless the person opted in. On the device, the match cache holds paragraph hashes and matches in memory per tab.
If that cache is persisted to speed up reloads, it should store hashes and short flagged snippets, not whole paragraphs, and expire within days (see the client storage lesson). The personal dictionary is the exception: it is the person's own data, synced to their account.
Security. The card shows text that came from the page and from the server. Rendering explanations or the flagged text with innerHTML turns a crafted email (an img tag with an onerror handler) into script running on the host page, let in by our extension. Every string is set with textContent or a framework's escaped rendering (see the XSS lesson). Our overlay and card live in a closed shadow root so the page's scripts can't easily read suggestions from our DOM.
Living with the browser's spellcheck and the host's own features
Browsers draw their own red squiggles in fields with spellcheck on, and two underlines under one word look broken. So for fields we actively check, the plain adapter can set spellcheck="false" on the field, which changes only an attribute, not the text, and restore the original value when we detach.
Some hosts have their own checkers too: Gmail and Google Docs offer spelling and grammar suggestions. Site adapters detect them and either step back for spelling or show only the categories the host doesn't cover. Our overlay must also stay below the host's own popups, like Gmail's address autocomplete, so it sits in the field's stacking context instead of at the maximum z-index.
Check your understanding · Exercise 9
Which fields should the privacy gate refuse to read? Select all that apply.
Architecture, data model and API
With the decisions made, the parts fall into three places: the content script in each frame, which touches the page; a checker worker for on-device checks; and the extension's background service worker, which owns the network, settings and rate limits. The page itself never sees our code except the overlay and card we draw.
| Part | Lives in | Responsibility |
|---|---|---|
| Field scanner and privacy gate | Content script | Finds fields, walks shadow roots, runs the gate, attaches one session per allowed field |
| Field adapter | Content script | readText, rectsFor, replace, change events for one kind of field |
| Field session | Content script | Version counter, edit log, paragraphs, suggestion store, the check state per paragraph |
| Anchor mapper | Content script | Maps suggestions and the saved caret through each edit, drops invalid ones |
| Overlay renderer | Content script, shadow root | Mirror element, measuring in one frame, underline boxes, hit testing |
| Card | Content script, shadow root | Explanation, fixes, dismiss, keyboard mode, live region |
| Local checker | Dedicated worker | Spelling dictionary in WebAssembly, simple rules, hashing |
| Check queue and API client | Background service worker | Batches paragraphs, token bucket across tabs, fetch with AbortController, retries |
| Settings and dictionary | Background, extension storage | Per-site toggles, personal dictionary, enterprise policy |
Why these boundaries? The adapter boundary isolates everything that differs between fields, so the session, mapper and renderer are written once. The local checker is in a worker because a dictionary lookup over 40 paragraphs, plus hashing, can take tens of milliseconds, which must not land on the host's main thread during typing.
The network lives in the background worker because it sees every tab and can enforce one rate limit across all of them, and it calls our API from the extension's own origin. Since Chrome 85, a content script's own fetch follows the same cross-origin rules as the page it runs in, so calls to our API belong in the background worker anyway.
insertText and comes back in as an input event, so the field stays the single source of truth.Follow one keystroke through the data flow. The host field fires input, and the adapter diffs the text into an edit for the session. The session bumps the version, maps every suggestion through the edit, and asks the renderer to redraw the affected paragraph on the next frame. The debounce timer for that paragraph restarts.
When the timer fires, the paragraph goes to the local checker and, if allowed, through the background worker to the API. Matches come back relative to the paragraph and version. The session maps them to the current text, merges local and server results (server wins when both flag the same range), and the renderer draws them. A fix travels the other way: card, session, adapter, host field, and then back in as an ordinary edit.
Think first
Why does the applied fix come back into the session as an ordinary edit, instead of the session updating its own state directly?
Because the host may not apply the fix exactly as we asked. A framework editor may cancel the beforeinput and apply its own change, normalise whitespace, or reject it. If the session updated itself directly, it would believe text that isn't in the field.
Treating the field as the single source of truth, and our state as a view of it rebuilt from observed edits, means one code path handles typing, fixes, pastes and host changes alike.
Data model and interfaces
Everything the content script holds is per field and short-lived: it disappears when the field is removed or the tab closes. Only settings, the personal dictionary and an optional match cache persist, in extension storage owned by the background worker.
type Category = "spelling" | "grammar" | "clarity" | "tone";
type Suggestion = {
id: string;
ruleId: string; // for "ignore this rule" and analytics
category: Category;
anchor: Anchor; // start, end, expected text
message: string; // plain text, rendered with textContent
fixes: string[]; // up to 3
source: "local" | "server";
checkedAtVersion: number;
};
type FieldSession = {
version: number;
text: string; // flat text as of version
editLog: { version: number; edit: Edit }[]; // last ~10 s
paragraphs: { start: number; hash: string; state: CheckState }[];
suggestions: Map<string, Suggestion>;
dismissed: Set<string>; // hash of ruleId + flagged text + context
};
Dismissals are keyed by rule, flagged text and a little context, hashed, not by offset. If the person dismisses "passive voice" on one sentence, then edits a different paragraph, the dismissal must survive even though every offset after the edit moved. The same key lets a dismissal survive a page reload when the text is the same.
The check API
{
"language": "en-GB",
"categories": ["grammar", "clarity", "tone"],
"paragraphs": [
{ "key": "p7:9f2c41", "text": "Their going to send the package tomorrow." }
]
}
{
"results": [{
"key": "p7:9f2c41",
"matches": [
{ "offset": 0, "length": 5, "category": "grammar", "ruleId": "THEIR_THEYRE",
"message": "Did you mean "They're"?", "replacements": ["They're", "They are"] }
]
}],
"retryAfterMs": null
}
Three details matter for the frontend. Offsets are UTF-16 code units, the same units JavaScript strings use, so an emoji before a word doesn't shift underlines by one. The server must document this, because many backends count Unicode code points instead.
Each paragraph has a key with its hash, so results can be matched to paragraphs even if they arrive out of order. Partial failure is normal: one paragraph can fail while others succeed, so errors are per paragraph, and a 429 carries a retry time that pauses the queue.
Inside the extension, messages between the content script and the background worker are small and typed: check with a tab-scoped request id and paragraphs, cancel with the request id, and result back. The background worker turns cancel into abort() on the fetch. The adapter interface from the field chapter is the other key contract; site adapters implement it and nothing else.
Check your understanding · Exercise 10
The text is Great 👍 job, recieve it. The server counts offsets in Unicode code points. What happens to the underline under recieve? Select all that apply.
Running it in the interview
In 60 minutes you can't build all of this, and the interviewer doesn't expect it. They want to see you find the guest-in-someone-else's-editor problem quickly, make the big calls with reasons, and go deep on two or three of them. A plan that works:
- Minutes 0 to 7: scope. Ask where it runs (any site or our own editor), which fields, whether text can leave the device, and the latency target. Say the four hard facts.
- Minutes 7 to 15: the field adapter and the overlay. Draw a textarea and a Gmail editor side by side: no rects in a textarea, the mirror element, and never writing inside the host's editable element.
- Minutes 15 to 25: the check pipeline. Hybrid checking, paragraphs with hashes, debounce, versions, cancelling, the state machine.
- Minutes 25 to 35: anchoring. The edit diff, mapping rules at the boundaries, the expected-text check, late responses mapped through the edit log.
- Minutes 35 to 45: applying a fix. The React trap,
execCommand('insertText'), verify and fall back, the caret. - Minutes 45 to 55: privacy, the card, accessibility. The privacy gate, focus that never leaves the field on click, the next-suggestion shortcut, a polite live region.
- Last 5 minutes: the architecture diagram with the three places code runs, and what you'd build first.
Draw first the picture that makes the problem obvious: a field you don't own with a separate layer floating over it. Everything else hangs off that picture. Say numbers out loud: 16.7 ms per frame, a 500 ms debounce, 20 requests a minute on a public API, 300 underlines on a long draft.
Think first
"We also have our own web editor built on ProseMirror. What would you do differently there?"
Almost everything gets simpler. We own the model, so suggestions become inline decorations in a plugin's state, and DecorationSet.map moves them through each transaction's position mapping, which replaces our edit diff and anchor mapper. The editor renders the underlines itself, so there is no overlay, no mirror and no drift.
Applying a fix is a normal transaction, so undo just works. What stays the same is the check pipeline: paragraphs, hashes, debounce, versions and the server API. A good answer reuses that part across both products.
Think first
"A person opens a 40-page document. What happens in the first five seconds?"
The adapter splits the text into paragraphs, maybe 400 of them, and the worker hashes them; the cache answers some. The local spelling check covers everything within a second, while the queue sends on-screen paragraphs first, in batches of a few KB.
Off-screen paragraphs wait until they scroll near the viewport, so we never send the whole document or hit the rate limit. The renderer measures only paragraphs near the viewport, so the first frame touches a few dozen ranges, not thousands.
Think first
"A popular site ships a new editor and our fixes start disappearing. How do you find out and limit the damage?"
Every apply is verified, so we can count verify failures per site and adapter without sending any text: a sharp rise on one hostname is the alarm. Site adapters sit behind remote flags, so we switch that site to read-only mode (underlines and cards, with a copy button instead of apply) within minutes, while someone writes a new adapter.
The point to make: in a guest product the host changes without telling you, so build detection and a kill switch into the design from day one.
Summary
- A writing assistant is a guest in editors it doesn't own: field adapters isolate how each kind of field reads text, measures it, and accepts changes.
- Never write inside the host's editable element. Draw underlines in an overlay, measured with
Range.getClientRects()for rich fields and a mirror element fortextareaandinput. - Check in paragraphs with hashes, debounce per paragraph, and treat every result as a claim about one version of the text.
- Keep suggestions on the right words by mapping their ranges through each edit and verifying the expected text before drawing or applying; drop what the edit touched.
- Apply fixes through
execCommand('insertText')so the host sees real input and undo works, then verify, with the native value setter as the fallback for plain fields. - Run spelling on the device and grammar on the server only for allowed fields; a privacy gate in front of everything decides which fields we read at all.
- The card never steals focus on click, offers a keyboard path to every suggestion, uses shapes as well as colours, and announces politely.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Range: getClientRects() method One rect per line box a range covers, the base of overlay underlines in rich fields.
- Document: execCommand() method Deprecated, but MDN notes it preserves the undo buffer, which is why fixes go through it.
- Element: beforeinput event How editors see intended edits, and
getTargetRanges(). - CSS Custom Highlight API Painting ranges without changing the DOM, for rich fields.
- spellcheck global attribute The browser's own checker and how sites turn it off.
- Google Docs will now use canvas based rendering Google's 2021 announcement and its warning about extensions.
- LanguageTool public HTTP proofreading API A real check endpoint, its match shape and its rate limits.
- React issue 10135: dispatchEvent on input or textarea is ignored Why assigning
valuedoesn't reach React state, and the native setter workaround. - textarea-caret-position The mirror element technique for measuring text inside a textarea.
- Changes to Cross-Origin Requests in Chrome Extension Content Scripts Why network calls go through the background worker, not the content script.
- Grammarly to shut down the Text Editor SDK in January The embeddable route Grammarly closed in 2024.
- The Proofreader API Chrome's experimental on-device proofreading API and its origin trial.
Concepts behind it
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Operational transformation (OT)A way to merge edits made at the same time: each incoming edit is rewritten, or transformed, to account for the edits it missed, so every copy ends with the same text. Google Docs is built on it.
- Layout, paint, and compositingWhat the browser redoes when something on the page changes after the first render, why moving a box with
transformis cheap while changing itswidthis not, and how to keep scrolling and animation inside a 16 ms frame. - ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.