cf.completefrontend
My progress
SYSTEM DESIGN · #137

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 textarea and input, rich contenteditable editors (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+Z can 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

NeedTargetWhy this number
Typing latency added0 ms visible, under 4 ms of main-thread work per keystrokeAt 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 underlineUnder 300 ms after a pause, on the deviceA dictionary lookup takes microseconds. The wait is our debounce, not the check.
Grammar underlineUnder 1.5 s after a pause (estimate)Debounce of about 500 ms plus a server round trip of 300 to 800 ms.
Text sizeUp 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 screenUp to a few hundredA rough first draft can have 1 problem every 20 words. A 3,000-word draft has 150.
AlignmentWithin 1 to 2 pixels of the textAn 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.

  1. We don't own the text. The host editor owns the document model. In a textarea the text is a single string with no DOM inside it. In a contenteditable editor 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.
  2. 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.
  3. We can't touch the host's DOM. If we wrap a misspelled word in a span inside 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.
  4. 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 kindRead the textFind a character on screenChange the text
textarea, inputel.value: one plain stringNot 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 elementRange.getClientRects() on a range over the text nodesSelect 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 modelSame as contenteditableSame, and never edit the DOM directly: the framework will undo it or crash
Canvas editors (Google Docs)No text in the DOM at allNo DOM rectsOnly through a dedicated integration with the host
Inside shadow roots or iframesSame as above, once we can reach the elementRects are relative to that frame's viewportSame as above, inside that frame
Three kinds of text fields side by side. A textarea holds one string in its value with no nodes inside, so there are no Range rects and we need a mirror element. A rich text field, a contenteditable editor, holds a paragraph node with text and bold children, so Range rects work, but the host owns that DOM. A canvas editor like Google Docs holds only pixels, with no text DOM, so it needs a host integration.textareavalue: stringno nodes insideno Range rectsmirror elementrich textptextbRange rects workhost owns the DOMcanvaspixels onlyno text DOMhost integrationeasy read, hard drawwalk text nodesits own adapter
Reading is easy in a 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?

One generic code path

Treat every field as a contenteditable-like element and read innerText.

Draw underlines from getClientRects().

Pros

  • Least code.
  • Works on simple sites.

Cons

  • Breaks completely for textarea and input, which are most fields on the web.
  • Can't handle canvas editors or editors that need special apply logic.

Field adapters behind one interface

A small interface: readText(), rectsFor(start, end), replace(start, end, text), onChange(callback).

One adapter per kind: plain field, contenteditable, and site-specific ones for big editors.

Pros

  • The checker, anchoring and card code never care which field they are in.
  • Site-specific fixes live in one place and ship without touching the core.

Cons

  • More code and a test matrix of sites times browsers.
  • Site adapters break when the site ships a redesign.

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?

Server only

Every changed paragraph goes to our API; a large model returns matches.

The client is thin: collect, send, render.

Pros

  • Best accuracy: big grammar and tone models, updated daily without shipping a new extension.
  • Small extension, works on slow laptops.

Cons

  • Text leaves the device, which some people and companies forbid.
  • Every underline waits for a round trip of 300 to 800 ms (estimate).
  • Offline means no checks at all.

Device only

A spelling dictionary compiled to WebAssembly, plus a small grammar model run with WebAssembly or WebGPU, inside a worker.

Or the browser's own on-device model where one exists.

Pros

  • Private: text never leaves the device.
  • Fast for spelling: a dictionary lookup is microseconds, so the debounce is the only delay.

Cons

  • Downloads of a few MB per language for dictionaries and tens to hundreds of MB for a model (estimate).
  • Weaker grammar and style, and old or low-memory laptops struggle.
  • Model updates need a download on every device.

Hybrid: device first, server for depth

Spelling and simple rules run in a worker on the device on every pause.

Grammar, clarity and tone go to the server for changed paragraphs only, if the person and the site allow it.

Pros

  • Spelling underlines appear almost instantly and work offline.
  • A privacy mode can turn the server off and still keep the basic product.

Cons

  • Two checkers can disagree or flag the same word twice, so results must be merged.
  • The most code to build and test.

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.

ts
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));
}
Splitting into paragraphs and finding the ones that need a check.

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.

CheckDebounce (estimate)Why
Spelling, on deviceAbout 150 ms after a word boundaryCheap to run. Waiting for a space or punctuation avoids flagging "recie" while the person is still typing "receive".
Grammar, serverAbout 500 ms after the last keystroke in that paragraphLong enough that most bursts of typing finish, short enough that the underline lands while the person still looks at the sentence.
Whole field on focusNo debounce, at most once per field per page viewThe 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.

State machine for one paragraph's check. Clean, where matches are valid, goes to Dirty on an edit. Dirty waits for a 500 ms debounce, and goes to In flight when the timer fires. In flight goes back to Clean when matches arrive, to Stale on another edit, and to Failed on an error. Stale either aborts the request or maps its late result, and returns to Dirty. Failed shows nothing new and retries back to In flight.cleanmatches validdirty500 ms debouncein flightAbortControllerstalemap or abortfailednothing shownedittimer firesmatcheseditabort or maperrorretry
Each paragraph has its own state, so a typo fix in one paragraph never cancels checks for the others. Failure is quiet: the old underlines stay, nothing new appears, and a retry follows.

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.

Sequence of a late check. The field sends input at version 17 to the session. The session asks the background worker to check paragraph 3 at version 17, and the worker posts it to the API. Meanwhile the field sends input at version 18. The API returns matches for version 17, the worker passes the result to the session, and the session maps it to version 18 and draws. Then the worker posts the next check, and the API answers 429 with Retry-After, so the queue pauses.FieldSessionBackgroundAPIinput: v17check p3 @ v17POST /v1/checkinput: v18matches for v17result v17map to v18, drawPOST next batch429, Retry-After: 5
The answer for v17 lands after v18 exists. The session maps it through the edit log instead of drawing at old offsets. On a 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?

Raw offsets

Keep the server's offset and length and draw there until the next check replaces them.

Pros

  • No code at all.

Cons

  • Wrong after any edit before the word, for up to a second until the next check lands.
  • Applying a fix at a stale offset corrupts text.

Map positions through each edit

Turn every change into an edit: position, deleted length, inserted length.

Shift ranges after the edit, keep ranges before it, drop ranges the edit touched.

This is the position-mapping half of operational transformation.

Pros

  • Constant work per suggestion per edit: a few comparisons.
  • Exact: knows precisely which suggestions an edit invalidated.

Cons

  • Needs to see every edit. If the host replaces the whole text at once (paste, undo, sync from another device), one big edit drops everything inside it.

Re-find by quote and context

Store the flagged text plus about 20 characters on each side.

After a change, search for that string near the old position.

Pros

  • Survives big replacements, reloads and edits we didn't see.

Cons

  • A string search per suggestion per change: too slow at hundreds of suggestions on every keystroke.
  • Repeated words like the the can match in the wrong place.

Re-check everything after each pause

Throw away all suggestions on every edit and wait for the next check.

Pros

  • Always consistent with the server.

Cons

  • Every underline flickers off on every keystroke and back after the debounce plus round trip.

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.

ts
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;
}
Mapping one suggestion through one edit. Typing at the start of a word moves it; typing at the end doesn't stretch it.

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.

One suggestion, recieve at characters 3 to 10 of We recieve mail, mapped through three edits. Typing now before it moves it to 7 to 14. Typing an x inside it drops it. Typing all after it keeps it at 3 to 10.v17Werecievemailflagged: 3 to 10beforeWenowrecievemailmoved: 7 to 14insideWerecxievemaildroppedafterWerecieveallmailkept: 3 to 10
Only an edit that touches the flagged characters invalidates a suggestion. Edits before it shift it by the change in length; edits after it leave it alone.
Strategy
Edits since v0
Anchoring lab using mapping. The server checked version 0, "We recieve teh mail tomorow.". The text is now "We now recieve teh mail tomorow.". recieve: moved to 7. teh: moved to 15. tomorow: moved to 24.We·now·recieve·teh·mail·tomorow.0102030recieve → receivemoved to 7teh → themoved to 15tomorow → tomorrowmoved to 24work: 3 comparisons
Every underline is either on the right word or safely dropped. Try other edits, or switch strategy.

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?

Wrap words in spans inside the editor

Insert a styled element around each flagged range.

Pros

  • Underlines move with the text for free.
  • Easy to build in an editor you own.

Cons

  • Impossible in textarea.
  • Corrupts or is erased by framework editors.
  • Spans can leak into what the person sends.

Overlay layer on top of the field

A positioned layer, owned by us, sits over the field with pointer-events: none.

Rects come from Range.getClientRects() for rich fields and from a mirror element for plain fields.

Each underline is a thin absolutely positioned box or one path in an SVG.

Pros

  • Works for every DOM field, never touches the host.
  • Full control of style, colour and hit testing.

Cons

  • Must be kept in sync with scroll, resize, fonts and zoom.
  • Measuring layout costs main-thread time if done carelessly.

CSS Custom Highlight API

Register Range objects with CSS.highlights.set(name, new Highlight(...ranges)) and style them with the ::highlight(name) pseudo-element.

Pros

  • The browser paints the underline as part of the text: no positions to track, no drift on scroll.
  • Supported in Chrome and Edge 105, Safari 17.2 and Firefox 140.

Cons

  • Needs ranges, so it can't reach inside textarea or input.
  • Only a few properties are allowed in ::highlight(), and text-decoration there came later in some browsers (Firefox only from version 146), so check before relying on wavy lines.
  • No hit testing: we still need our own logic to find which suggestion was clicked.

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.

Three layers, drawn apart. At the bottom, the host textarea shows the text. In the middle, our hidden mirror copies the field's styles and wraps flagged words in spans. At the top, the overlay draws underlines at the span rects minus the field's scroll offset, with pointer events off. A note says we never write inside the field.overlay: underlines onlymirror: hidden, our spanshost textareacopy styles, textrects minus scrollnever write insidethe host field
The mirror is where measuring happens, because it has real nodes. The overlay only draws, and with 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.

ts
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";
}
Building the mirror. One missing property and underlines drift; the overlay lab below shows how.

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.

Field
Mirror copies
Overlay lab. A 420 wide textarea with font 16, padding 12, scrolled 0 lines. Flagged words are highlighted, and the overlay's underlines come from the mirror. Every underline is aligned.textarea, 420 widealigned
Aligned: the mirror lays out text exactly like the field, so each underline sits under its word. Turn off a copied style, then change the field, to see it drift.

Staying aligned when anything moves

What changesHow we noticeWhat we redo
The field scrollsscroll listener on the field, passiveOnly the scroll offset: a single transform on the overlay's inner layer, no re-measure
The page scrollsNothing, if the overlay is placed in the same scroll container as the fieldNothing. Placing it in document.body instead would need a re-position on every scroll
The field resizes or the window zoomsResizeObserver on the fieldRe-sync the mirror width and re-measure visible paragraphs
Fonts finish loadingdocument.fonts.ready and the loadingdone eventRe-measure everything: text widths changed
The host changes styles or classesMutationObserver on the field's style and class attributesRe-sync mirror styles, re-measure
The field moves on the page (layout shift)Compare getBoundingClientRect() once per frame while the field is focusedMove 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?

Native value setter plus an input event

Call the setter from HTMLTextAreaElement.prototype (or HTMLInputElement.prototype) on the element, bypassing React's tracker.

Dispatch new Event('input', { bubbles: true }).

Pros

  • React, Vue and plain listeners all see a normal input event and update their state.
  • Works in every browser.

Cons

  • Plain fields only.
  • The browser's undo stack is lost: Ctrl+Z can't undo the fix.
  • The caret jumps to the end unless we restore it.

Select the range, then execCommand('insertText')

Focus the field, select the flagged range (setSelectionRange for plain fields, a DOM Range for rich ones), then document.execCommand('insertText', false, fix).

Pros

  • The browser performs a real edit and fires an input event with inputType insertText; some browsers fire beforeinput first too. Framework editors usually treat it like typing.
  • The change joins the browser's undo history.
  • The caret ends up after the inserted text.

Cons

  • execCommand is deprecated and non-standard, though MDN notes it still has valid uses, such as edits that keep the undo history, with no replacement yet.
  • Some editors cancel the edit and apply their own change, so we must verify the result.

Dispatch a synthetic beforeinput or paste event

Create an InputEvent or ClipboardEvent with the fix as data and dispatch it at the field.

Pros

  • Some framework editors handle these events themselves and update their model.

Cons

  • Script-created events are untrusted: the browser never performs the edit itself, so nothing happens unless the host's own code happens to handle the event.
  • Behaviour differs per editor, so it only fits a site adapter.

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.

ts
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;
}
Applying a fix in a plain field, with the verify step and the fallback.
Sequence of applying a fix, showing the failure path. The Card asks the Adapter to apply the fix. The Adapter reads the range in the Host field, which still says recieve. The Adapter selects the range and calls insertText. It reads the text back, and here the host ignored or cancelled the edit, so the fix is not there. The Adapter falls back to the native setter and an input event. The Host field's input event reaches the Session as an edit, and the Session remaps the other suggestions and closes the card.CardAdapterHost fieldSessionapply(fix)read rangestill recieveselect, insertTextread backno fix therenative setter, inputinput: editremap, close card
The happy path is three steps: verify, 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.

Wireframe of a Gmail reply with the assistant. The field reads: Thanks for the update. I will recieve the box tomorow. Recieve and tomorow have underlines. Below recieve, an open card shows the category Spelling, the text Did you mean, two fix buttons, receive and relieve, and three secondary actions: Dismiss, Add word, and Off here. A note says focus stays in the field.Gmail: replyThanks for the update. I willrecieve the box tomorow.SpellingDid you mean:receiverelieveDismissAdd wordOff hereexplanationfixes: buttonssecondaryfocus staysin the field
The card sits next to the underline and never covers the line being typed. Fixes are the biggest targets; dismiss, add to dictionary and turning the site off are smaller and always present.

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 never email, tel, number, or one-time code fields. These are not prose, and checking them only creates risk.
  • Autocomplete hints. Fields with autocomplete values like cc-number, cc-csc, one-time-code or current-password hold 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" and data-enable-grammarly="false" on a field. Our extension should honour the same well-known attributes plus its own, and respect spellcheck="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.
The privacy gate as a flow. A new field is found. Four checks follow in order: field type, such as password, card number or one-time code; opt-out attributes, such as data-gramm false; the site list and enterprise policy, such as banking; and the person's own toggle for this site. Any match goes to Never read text, with no listeners and no overlay. Only a field that passes all four gets an adapter attached.New field foundField typepassword, cc-number, otpOpt-out attributedata-gramm=falseSite list, policybanking, admin blockPerson's toggleoff for this siteNever read textno listeners, no overlayany matchAttach adapterall clear
The gate runs before any listener is added, so a blocked field's text is never read, not even into memory. 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.

The writing assistant's architecture. In the page, the content script has a Scanner with the privacy gate, which attaches an Adapter to the Host field. The Adapter feeds a Session that holds versions and anchors, and stores Suggestions per paragraph. The Session drives the Overlay and the card, both in our shadow root. Below, a dedicated worker runs the Local checker with a WebAssembly dictionary for spelling. The background worker runs the Check queue, which batches paragraphs and applies rate limits, and the Settings store. The queue calls the Check API on the server for grammar and tone.Page: content scriptHost fieldAdapterread, measureSessionversionsOverlayand the cardScannerprivacy gateSuggestionsper paragraphattachDedicated workerLocal checkerWASM dictionaryBackground workerCheck queuebatch, rate limitSettingsCheck APIgrammar, tonespellinggrammar
Only the content script touches the page, and only the adapter touches the host field. Checking happens off the main thread: spelling in a worker, grammar through the background worker, which owns the network and one rate limit for all tabs.
PartLives inResponsibility
Field scanner and privacy gateContent scriptFinds fields, walks shadow roots, runs the gate, attaches one session per allowed field
Field adapterContent scriptreadText, rectsFor, replace, change events for one kind of field
Field sessionContent scriptVersion counter, edit log, paragraphs, suggestion store, the check state per paragraph
Anchor mapperContent scriptMaps suggestions and the saved caret through each edit, drops invalid ones
Overlay rendererContent script, shadow rootMirror element, measuring in one frame, underline boxes, hit testing
CardContent script, shadow rootExplanation, fixes, dismiss, keyboard mode, live region
Local checkerDedicated workerSpelling dictionary in WebAssembly, simple rules, hashing
Check queue and API clientBackground service workerBatches paragraphs, token bucket across tabs, fetch with AbortController, retries
Settings and dictionaryBackground, extension storagePer-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.

Data flow. The Host field sends an input event to the Adapter, which turns it into an edit for the Session. After the debounce, the Session sends a dirty paragraph to the Queue, which posts paragraphs to the Check API. The API returns matches by paragraph key to the Session, which maps them through the edit log and gives anchored ranges to the Overlay. A click on the Overlay opens the Card. A fix goes from the Card to the Adapter as replace, and the Adapter calls insertText on the Host field, which comes back as an ordinary input event.Host fieldAdapterSessionQueueinput eventinsertTexteditdirty paragraphCardOverlayCheck APIPOST /v1/checkmatches by keyanchored rangesclickfix: replace()
Data only flows one way into the session: as edits observed on the field. Even our own fix goes out through 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?

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.

ts
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
};
Client-side entities for one field.
The data model. In content script memory, per field: the FieldSession with version and text, which owns Paragraphs with start, hash and state, Suggestions with anchor and fixes, the Edit log of the last 10 seconds, and Dismissed keys made of a rule and text hash. All of it dies with the field. In extension storage: Settings with per-site toggles, the personal Dictionary, which is synced, and an optional Match cache of hashes that expires in days.Content script memory, per fieldFieldSessionversion, textParagraphsstart, hash, stateSuggestionsanchor, fixesEdit loglast ~10 sDismissedrule + text hashdies with the fieldExtension storageSettingsper-site togglesDictionarysyncedMatch cachehashes, days
Everything about the text is in memory and short-lived. What persists is about the person, not their text: settings, their own dictionary, and at most a short-lived cache of hashes and flagged snippets.

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

json
{
  "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
}
POST /v1/check from the background worker: the request, then the response. Offsets are UTF-16 code units, relative to each paragraph.

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:

  1. 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.
  2. 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.
  3. Minutes 15 to 25: the check pipeline. Hybrid checking, paragraphs with hashes, debounce, versions, cancelling, the state machine.
  4. Minutes 25 to 35: anchoring. The edit diff, mapping rules at the boundaries, the expected-text check, late responses mapped through the edit log.
  5. Minutes 35 to 45: applying a fix. The React trap, execCommand('insertText'), verify and fall back, the caret.
  6. 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.
  7. 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?"

Think first

"A person opens a 40-page document. What happens in the first five seconds?"

Think first

"A popular site ships a new editor and our fixes start disappearing. How do you find out and limit the damage?"

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 for textarea and input.
  • 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.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next