cf.completefrontend
My progress
SYSTEM DESIGN · #31

Design a markdown editor with live preview

A text box where people write markdown and see it rendered as they type. The hard parts are smart editing that keeps native undo, rendering fast enough for long documents, never letting user HTML run as script, keeping the source and the preview scrolled together, pasting images, and saving drafts that survive a crash.

The question

Design a reusable markdown editor for our product. People use it to write issue descriptions, comments and long docs pages. They type markdown on one side and want to see what it will look like before they post it.

Walk me through how the editing surface works, how markdown becomes the preview and how fast that is, how you keep it safe when anyone can type HTML, how the preview scrolls with the source, how images get in, and what the component API looks like for the teams that embed it.

Real-life examples: The Write and Preview tabs in GitHub issues and pull requests, The Stack Overflow question and answer editor, The dev.to post editor, HackMD with its side-by-side preview, Obsidian and Bear, which style markdown as you type, Docs tools with a split source and preview pane

Requirements: what to ask before drawing anything

Markdown is a plain text format where a few symbols mean formatting: **bold**, # Heading, - list item, [link](https://example.com). A markdown editor lets you type that text and shows a preview: the same text turned into real HTML, with real headings, lists and links. You've used one in a GitHub issue, a Stack Overflow answer, a dev.to post, HackMD, or a notes app like Obsidian or Bear.

The text people type is the source. The thing we save and send to the server is that source, not the HTML. That one fact shapes the whole design: the preview is a view we can always rebuild, so it can be slow to update, thrown away, or rendered in a worker. The source is the precious part.

Several neighbours have their own guides, so this one links to them instead of repeating them. The rich text editor guide covers editing formatted text directly (WYSIWYG, short for what you see is what you get). The LLM chat UI guide covers rendering markdown that streams in token by token. The CodePen guide compares code editor libraries in depth.

The mention input guide covers finding the caret and handling input method composition in a textarea. The tabs guide covers the tab pattern behind Write and Preview, and the file uploader guide covers the upload itself. Here we focus on what is left: a source-plus-preview markdown editor.

Ask these questions first. Each answer moves the design. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Who writes here?Developers and non-developers. Many know **bold**, many don't.We need a toolbar and shortcuts, not only raw syntax.
How long are documents?Comments are 200 to 2,000 characters. Docs pages reach 50,000 characters (about 1,500 lines). We test with 200,000.Short text can re-render on every pause. Long text needs a worker and stable DOM.
Which markdown flavor?CommonMark plus GitHub Flavored Markdown (GFM): tables, task lists, strikethrough, autolinks.We pick a parser that follows CommonMark and covers GFM, and run the same one on the server, so the preview matches the post.
Can people write raw HTML?Yes, some (like details and kbd), because CommonMark allows it.Sanitizing becomes a core part of rendering, not an afterthought.
Where is the final HTML made?On the server, with the same parser and rules.The client preview is a fast guess; the server render is the truth.
Images?Paste or drag images; they upload to our storage and become a markdown link.An upload flow with placeholders inside the text.
Code, math, diagrams?Code highlighting yes; math (KaTeX) and Mermaid diagrams on docs pages only.Heavy renderers load lazily and only when the text uses them.
Devices?Desktop first; phones must work for comments.Split view on wide screens, Write and Preview tabs on narrow ones.
Accessibility target?WCAG 2.2 AA.No keyboard trap in the text box; tabs and preview must be announced well.

Functional requirements

  • Type markdown in a text area with helpful behaviors: continue lists on Enter, indent with Tab, wrap a selection in ** with Ctrl or Cmd+B.
  • See a preview: in a Preview tab on narrow screens, side by side on wide screens.
  • The preview supports CommonMark and GFM, highlighted code blocks, and (where turned on) math and diagrams.
  • Paste or drop images; they upload and appear in the text as ![name](url).
  • Drafts save on their own and come back after a reload or a crash.
  • Teams embed it with a small API: value, change handler, plugins, sanitize rules, an upload handler.

Non-functional requirements

  • Typing never waits for the preview. A key press shows up in the text in under 16 ms, one frame, even on a 50,000 character document.
  • The preview catches up within about 200 ms after the person pauses.
  • Safe by default: no script from user content ever runs, in the preview or in the posted result.
  • The editor shell adds under 15 KB gzipped to the page. The parser and sanitizer load on first focus; highlighting, math and diagrams load only when the text uses them.
  • Works with keyboard only and with screen readers.
  • No lost work: at most a few seconds of typing can be lost if the tab crashes.

Out of scope

  • Real-time collaboration with several cursors. That needs OT or CRDTs, covered in the Google Docs guide.
  • Full WYSIWYG editing, covered in the rich text editor guide.
  • @-mentions and their popup, covered in the mention input guide.
  • The server's storage and search of posts.

Check your understanding · Exercise 1

Which answers to the scope questions change the design the most? Select all that apply.

What makes this hard

A markdown editor looks like a textarea next to a div. Wire onChange to a parser, set innerHTML, done. That version takes ten minutes to build and breaks in five ways as soon as real people use it. Each way gets its own chapter or section below.

  • Two audiences, one surface. Power users want raw syntax and speed. Beginners don't know that ** means bold. The editing mode (source, split, or styled-as-you-type) decides who is happy.
  • Typing must stay instant while the preview is expensive. A fast parser handles 50,000 characters in a few milliseconds on a laptop, and several times that on a mid-range phone. Replacing the whole preview DOM costs far more: the browser rebuilds, styles and lays out every block, and every image and embed reloads. Do that on every key press and typing stutters.
  • The input is hostile. CommonMark lets people write raw HTML. Anyone can type an img with an onerror handler or a link to javascript:alert(1). The preview runs on our origin, next to the user's session, so a missed case is a stored cross-site scripting (XSS) bug.
  • Two views of one document. The source is lines of text. The preview is boxes of different heights: a 3 line table can be 400 pixels tall, a 40 line code block can be short when folded. Scrolling one and keeping the other in step needs a map between them.
  • Edits come from many places. The person types, the toolbar inserts **, Enter continues a list, a finished upload swaps a placeholder for a URL, a draft gets restored. Every one of these must keep the caret in the right place and keep Ctrl or Cmd+Z working.

Notice what is not on the list: storing the content. The value is a plain string. It's easy to save, diff, search and send. That is markdown's biggest advantage over a rich text document model, and it's why GitHub, Stack Overflow and most developer tools chose it.

Here is the whole component as the reader sees it. Keep it in mind; every chapter fills in one part.

Wireframe of the markdown editor in split view. The top row has three mode tabs, Write, Preview and Split, with Split selected, and a toolbar with Bold, Italic, Code, Link and Image buttons. Below, the source pane on the left shows a heading line, two task list lines and an uploading image placeholder. The preview pane on the right shows the rendered heading, two checkboxes and a gray box for the image that is still uploading. The status line at the bottom says Markdown supported, press Escape then Tab to leave, and Draft saved 2 seconds ago.Edit: Install guideWritePreviewSplitBIcodelinkimg# Install- [x] Node 20- [ ] npm install![Uploading shot.png...]()source: textareaInstallNode 20npm installpreview: regionMarkdown supported. Esc then Tab leaves.Draft saved 2 s ago
One engine, two layouts: on narrow screens the Split tab disappears and Write and Preview take turns. The status line carries the keyboard hint and the draft state, so nothing needs a popup.

Think first

If the saved value is just a string, why not render the preview on the server, like the final post, and skip the client parser entirely?

Source, split, or styled as you type

The first decision is what the writing surface looks like. It sounds like a visual choice, but it picks the editing engine, the bundle size, and who the product serves. Products made different calls here, and each call fits its audience.

The naive answer is the tab design: one textarea, and a Preview tab that hides it. It's what GitHub ships for comments, and it works well for short text.

On a 3,000 word docs page it breaks. You write a table, switch tabs to check it, switch back, and the caret and scroll position have to be restored. People end up flipping tabs dozens of times.

Three editing modes as three small windows. Tabs: one pane with markdown source and a Write and Preview tab row; you see one at a time. Split: source on the left half and the rendered preview on the right half, both visible. Hybrid: one pane where the heading and bold text are styled in place, and only the line with the caret shows its raw stars.tabsWritePreview# Title**bold** textsplit# Title**boTitleboldhybridTitlebold text**caret line**GitHub commentsHackMD, docsObsidian, Typora
Tabs and split share one engine, a source editor plus a renderer, so a product can offer both by width. The hybrid styles the source itself, which needs a full editor engine like CodeMirror 6.

Decision Which editing mode should the component offer?

Source with a Preview tab

A plain text box. A Write and Preview tab pair swaps it for the rendered result.

GitHub issues and pull requests, many comment boxes.

Pros

  • Smallest and simplest. Works on the narrowest phone.
  • The preview can render on demand, even on the server.
  • No scroll sync needed.

Cons

  • You can't see the result while typing.
  • Flipping tabs breaks flow on long documents.

Split view: source and live preview side by side

Source on the left, preview on the right, updated after each pause.

HackMD, the VS Code markdown preview, many docs tools.

Pros

  • Instant feedback on tables, nesting and images.
  • The source stays raw, so power users keep full control.

Cons

  • Needs about 1,000 pixels of width; useless on phones.
  • Needs fast rendering and scroll sync.
  • Two copies of the content on screen.

Live-preview hybrid (styled source)

One pane. Markdown is styled in place: **bold** shows bold, and the markers hide when the caret leaves the line.

Obsidian's Live Preview mode, Typora, Bear.

Pros

  • Feels like a word processor, but the file stays plain markdown.
  • Great for beginners and for notes apps.

Cons

  • Needs a real editor engine (CodeMirror 6 or similar), not a textarea.
  • Hiding markers makes the caret jump in surprising ways.
  • Tables, nested lists and HTML blocks are hard to edit in place.

What we pick: Offer the first two, picked by width. Under about 900 pixels, Write and Preview tabs. Above it, a split view with a toggle to hide the preview. That serves short comments and long docs pages with one engine: a source editor plus a rendered preview. The hybrid is a different product (a notes app) and pulls in a full editor engine; we leave it out, but our pipeline design does not block it later.

Why not WYSIWYG, like Google Docs? Because then the saved value is a document tree, and markdown becomes an import and export format that loses things. Stack Overflow's newer editor offers both a rich mode and a markdown mode on top of ProseMirror, and keeping the two in sync is a project in itself. The rich text editor guide covers that path.

Switching modes without losing your place

The mode is a view setting, not data. Store it as view: "write" | "preview" | "split" in the editor's state and remember the person's last choice in localStorage. Two details matter when switching:

  • Keep the textarea mounted when the Preview tab is showing; hide it with the hidden attribute. Unmounting it throws away the native undo history and the selection.
  • When going back to Write, put focus back on the text box only if the person pressed the Write tab with a click or key. Never steal focus on a resize from split to tabs.

Check your understanding · Exercise 2

A team wants the editor for short code review comments on phones and laptops. Which statements are true? Select all that apply.

The editing surface, programmatic edits and undo

Now pick the element people type into. A plain textarea gives you the browser's own caret, selection, spellcheck, autocorrect, input method editors (IMEs, used to type Chinese or Japanese), mobile keyboards, and undo, all for free. A code editor library gives you syntax colors, line numbers and a real editing API, at a cost.

Decision What should people type into?

Plain textarea with smart behaviors

A native textarea. A keydown handler adds list continuation, Tab indent and selection wrapping.

GitHub's comment box works this way; GitHub also open-sourced a toolbar for it as the markdown-toolbar-element web component.

Pros

  • Near zero bundle cost. Native mobile, IME, spellcheck and accessibility.
  • The browser's undo keeps working if edits go through execCommand.
  • Every form library understands it.

Cons

  • No syntax colors inside the text.
  • No line numbers or folding.
  • Each smart behavior is hand-written against selectionStart and selectionEnd.

CodeMirror 6 with the markdown language

@codemirror/lang-markdown parses the text with the Lezer parser and styles headings, emphasis and code fences as you type.

Obsidian builds on CodeMirror 6; the CodePen guide compares it with Monaco.

Pros

  • Syntax colors, nested code highlighting, line numbers, search.
  • Transactions and its own undo history make programmatic edits clean.
  • Handles 100,000 line documents by rendering only the visible lines.

Cons

  • A large bundle compared to a textarea, so it must be lazy loaded.
  • Its own undo and keymap: more to learn, more to test.
  • Mobile and IME behavior is good but not native.

contenteditable with spans for syntax

A div with contenteditable, re-wrapping tokens in colored spans as you type.

Pros

  • Colors with no big library.

Cons

  • Re-wrapping spans moves the caret and breaks undo and IME composition.
  • Every browser behaves differently. This is the trap the rich text editor guide warns about.

What we pick: Default to the smart textarea: it fits comments and most docs pages, costs almost nothing, and inherits native accessibility and mobile behavior. Put CodeMirror 6 behind the same component API as an opt-in surface: "code" for docs teams who want syntax colors and very long files, and load it with a dynamic import(). Avoid hand-rolled contenteditable.

The smart behaviors people expect

These are small, but they are what makes a textarea feel like an editor. Each reads the current line around the caret and replaces a range.

KeyWhat happensEdge cases
Enter inside a list itemInsert a newline plus the same marker: - , * , 1. becomes 2. , - [ ] stays unchecked.On an empty item, remove the marker and end the list instead. Shift+Enter inserts a plain newline.
Tab and Shift+TabIndent or outdent the selected lines by two spaces (or four inside ordered lists).Must not trap keyboard users; see below.
Ctrl or Cmd+B, I, EWrap the selection in **, _, or backticks. Pressed again on a wrapped selection, unwrap it.With no selection, insert the pair and put the caret between.
Typing (, [ or a backtick over a selectionWrap the selection instead of replacing it.With no selection, insert one character as normal. Auto-pairing on every bracket annoys prose writers, so keep it to selections.
Pasting a URL over selected textTurn it into [text](url).Only when the clipboard is a single valid URL.

Here is the Enter handler for lists. It finds the current line, matches a list marker, and either continues or ends the list. The insert helper comes later in this chapter: it is how we keep undo working.

ts
const LIST = /^(\s*)([-*+]|\d+[.)])(\s+\[[ xX]\])?\s+/;

function onEnter(el: HTMLTextAreaElement, e: KeyboardEvent) {
  if (e.isComposing || e.keyCode === 229 || e.shiftKey || el.selectionStart !== el.selectionEnd) return;
  const pos = el.selectionStart;
  const lineStart = el.value.lastIndexOf("\n", pos - 1) + 1;
  const line = el.value.slice(lineStart, pos);
  const m = LIST.exec(line);
  if (!m) return;
  e.preventDefault();
  if (line.length === m[0].length) {
    // Empty item: remove the marker and end the list.
    el.setSelectionRange(lineStart, pos);
    insert(el, "");
    return;
  }
  const [, indent, marker, task] = m;
  const next = /\d/.test(marker) ? `${parseInt(marker) + 1}${marker.slice(-1)}` : marker;
  insert(el, `\n${indent}${next}${task ? " [ ]" : ""} `);
}
List continuation on Enter

Note the composition check. While someone types with an IME, Enter confirms the composed word; it must not continue a list. Safari has long fired that confirming keydown after compositionend with isComposing false, so the handler also checks keyCode 229. The mention input guide explains composition events in more depth.

Tab without a keyboard trap

In a normal textarea, Tab moves focus to the next control. If we take Tab for indenting, a keyboard user can get stuck. WCAG 2.1.2 (No Keyboard Trap) says focus must be able to leave using only the keyboard, and if that takes more than the standard keys, the person must be told how.

CodeMirror 6 handles this by not binding Tab at all unless you add indentWithTab. When Tab is bound, pressing Escape and then Tab still moves focus out, and Ctrl+M (Shift+Alt+M on macOS) toggles a mode where Tab always moves focus. Its docs ask you to tell people about these escape hatches. We copy the rule:

  • Tab indents only when the caret is in a list or a code fence, or when lines are selected. On a plain prose line, Tab moves focus as usual.
  • After Escape, the next Tab or Shift+Tab always moves focus. Any other key turns indenting back on.
  • Say so in the help text under the editor, and in the aria-describedby text of the text box.

Try it. The lab below is a small textarea with these behaviors. Press Enter in the list, Tab and Shift+Tab, select a word and press the bold button, then Escape and Tab. The log shows each edit and whether it landed on the native undo stack.

Edits use

Edit log, newest first, with edits applied through execCommand. No edits yet. The text has 5 lines.newest edit firston the undo stack?Press Enter at the end of a list line to startedits via execCommand, 5 lines
Every smart edit lands on the browser's own undo stack, so Ctrl or Cmd+Z reverses exactly one edit. In browsers that report it, the log shows the undo arriving as a native historyUndo event.

Check your understanding · Exercise 3

Someone is on the line 3. with nothing after the marker and presses Enter. What should happen? Select all that apply.

Programmatic edits that keep undo

Every smart behavior, toolbar button and upload changes the text from code. The obvious way is el.value = newText. Do that, then press Ctrl or Cmd+Z: in most browsers the undo history is gone or confused, because the browser can't tell what changed. People lose work and stop trusting the editor.

Decision How should code change the text so undo still works?

Set value (or setRangeText)

Build the new string, assign el.value, then fix the caret with setSelectionRange.

setRangeText replaces a range without rebuilding the string.

Pros

  • Simple, standard, not deprecated.
  • setRangeText keeps the caret logic small.

Cons

  • In current browsers neither edit is recorded as an undo step, and setting value usually clears the history.
  • No input event fires, so frameworks miss the change unless you dispatch one.

document.execCommand('insertText')

Focus the text box, select the range to replace, call document.execCommand("insertText", false, text).

Pros

  • The browser records it as a normal typing step, so Ctrl or Cmd+Z undoes exactly that edit.
  • Fires a real input event, so React's onChange runs.
  • Works for textarea in current Chrome, Edge, Safari and Firefox (Firefox since version 89).

Cons

  • Marked deprecated on MDN, though no replacement exists for this use.
  • The element must have focus.

Own undo stack

Keep a list of snapshots or diffs; intercept Ctrl or Cmd+Z and the beforeinput events historyUndo and historyRedo.

Pros

  • Full control: group edits, undo an upload as one step.
  • The undo and redo guide covers this design in depth.

Cons

  • Must also capture plain typing, IME input, autocorrect, drag and drop.
  • The Edit menu and mobile shake-to-undo still talk to the native stack.

What we pick: Use execCommand("insertText") for every programmatic edit, with a setRangeText fallback (plus a dispatched input event) if it returns false. It is the only way to join the native undo stack today. GitHub's open source markdown-toolbar-element does the same with a similar fallback, and the comments thread guide's toolbar makes the same choice. If you use the CodeMirror surface, use its transactions instead: they go into CodeMirror's own history.

ts
export function insert(el: HTMLTextAreaElement, text: string) {
  el.focus();
  const ok = document.execCommand("insertText", false, text);
  if (!ok) {
    // Fallback: correct text, but this step won't be undoable.
    el.setRangeText(text, el.selectionStart, el.selectionEnd, "end");
    el.dispatchEvent(new Event("input", { bubbles: true }));
  }
}

export function wrap(el: HTMLTextAreaElement, mark: string) {
  const { selectionStart: s, selectionEnd: e, value } = el;
  const inner = value.slice(s, e);
  const wrapped = value.slice(s - mark.length, s) === mark && value.slice(e, e + mark.length) === mark;
  if (wrapped) {
    el.setSelectionRange(s - mark.length, e + mark.length);
    insert(el, inner);
  } else {
    insert(el, mark + inner + mark);
    el.setSelectionRange(s + mark.length, s + mark.length + inner.length);
  }
}
One helper for every programmatic edit

The figure follows one Enter key press from the keyboard to the preview. Notice that the text box updates in the same frame, while the preview waits for a pause. That split is the heart of the design.

Sequence diagram of pressing Enter in a list. The user presses Enter in the textarea. The textarea sends keydown to the onKeyDown handler, which sees a list line and calls insertText with a newline and the next marker. The textarea shows the new list item in the same frame. The textarea fires an input event, and onChange tells the scheduler about the new text. The scheduler waits for a 150 millisecond pause, then asks the preview to render version 8, and the preview reports that it patched one block.UserTextareaonKeyDownSchedulerPreviewEnterkeydowninsertTextsame frameonChangewait 150 msrender v81 block
Everything above the dashed reply to the user happens inside one frame. The preview work starts only after the pause, and because the edit went through insertText, one Cmd+Z removes the new marker.

A toolbar that teaches the syntax

The toolbar is for people who don't know markdown yet. Each button inserts syntax rather than hiding it, so after a few clicks people learn that ** means bold and start typing it. Build it as the WAI-ARIA toolbar pattern: a div with role="toolbar" and an aria-label, aria-controls pointing at the text box, and arrow keys moving between buttons so the toolbar is one Tab stop.

A button click moves focus away from the text box, but the textarea keeps its selectionStart and selectionEnd while blurred. So a button handler just calls wrap(el, "**"): the helper focuses the text box, and the edit lands where the selection was.

ShortcutActionInserted text
Ctrl or Cmd+BBold**text**
Ctrl or Cmd+IItalic_text_
Ctrl or Cmd+EInline codea backtick on each side
Ctrl or Cmd+KLink[text](url) with url selected, ready to paste over
Ctrl or Cmd+Shift+PToggle Write and Previewnone; GitHub uses this same shortcut

Make shortcuts discoverable: show them in each button's tooltip ("Bold, Ctrl+B", or "Cmd+B" on a Mac), list them with aria-keyshortcuts on the buttons, and add a small "Markdown is supported" link to a cheat sheet. Bind them on the text box's keydown, not the window, so they don't fight other shortcuts on the page.

Check your understanding · Exercise 4

A finished image upload replaces ![Uploading cat.png...]() with the real link. The person then presses Cmd+Z. What should be true? Select all that apply.

From markdown to preview, fast

Markdown has a spec now. CommonMark (2014 onward) pins down the hundreds of edge cases the original 2004 Markdown left vague, like how a list nested in a quote behaves. GitHub Flavored Markdown (GFM) is a strict superset that adds tables, task lists (- [x] done), strikethrough (~~old~~), extended autolinks (a bare www.example.com becomes a link), and a filter for some dangerous raw HTML tags. Users copy text between GitHub and our product, so we want both.

Decision Which parser should turn the source into HTML?

marked

Turns markdown straight into an HTML string with a lexer and renderer. GFM is on by default.

Pros

  • Very fast and small. Simple API: marked.parse(text).
  • Easy custom renderers for headings, code and links.

Cons

  • Less strict CommonMark compliance in rare edge cases.
  • No source line info on its HTML output, so scroll sync needs extra work.
  • Does not sanitize; its docs point you to DOMPurify.

markdown-it

Parses into a flat list of tokens, then renders them. Every block token carries map: its start and end source lines.

Pros

  • Fast, follows CommonMark, GFM tables and strikethrough built in.
  • Large plugin ecosystem: task lists, footnotes, KaTeX, anchors.
  • html: false by default escapes raw HTML; validateLink blocks javascript: URLs.
  • Token map makes scroll sync easy.

Cons

  • GFM is emulated with built-ins and plugins (tables, strikethrough, linkify, task lists), not tested against the GFM spec, so rare edge cases differ from GitHub.
  • Plugins mutate a shared token stream, which can get messy.
  • Output is an HTML string, so React needs dangerouslySetInnerHTML or a token-to-element step.

unified: micromark, remark and rehype

micromark tokenizes; remark builds mdast (a markdown syntax tree); remark-rehype turns it into hast (an HTML tree); rehype-sanitize cleans it; you stringify or render to React.

Pros

  • micromark passes the whole CommonMark test suite; remark-gfm adds GFM as GitHub actually renders it.
  • Every node has a position with lines and columns.
  • Trees are easy to transform, lint, and sanitize without a DOM, even in a worker.

Cons

  • Several times slower: it builds two full trees. A public 2026 benchmark (web-markdown-benchmark) measured remark plus rehype at roughly 5 to 8 times markdown-it's time.
  • Many small packages; a bigger bundle for the full pipeline.

What we pick: Use markdown-it for the live preview: fast, CommonMark compliant, with line maps for scroll sync and a mature plugin set. Use the same version and options on the server for the final render, so preview and post match. If the product already transforms markdown on the server with unified (to lint links or build a table of contents), or must match GitHub's output exactly, use unified on both sides instead: matching the server matters more than a few milliseconds.

When to re-render: every key, after a pause, or by block

Naive: re-parse and set preview.innerHTML on every input event. On a 50,000 character page, parsing takes a few milliseconds on a laptop, but rebuilding, styling and laying out 1,500 lines of DOM takes far longer. On a mid-range phone, a rough estimate is 30 ms or more of main thread work per key press. That's two dropped frames per key, and every image in the preview reloads each time, so the preview flickers.

Decision How should the preview keep up with typing?

Re-render on every key press

Parse and replace the preview on each input event.

Pros

  • Always current. Fine under about 2,000 characters.

Cons

  • Cost grows with document size; long pages stutter.
  • Images, iframes and diagrams reload on each key.

Debounce, then full re-render

Wait until typing pauses for 150 ms, then parse and replace. The debounce and throttle lesson covers the timer.

Pros

  • Typing stays smooth; work happens once per pause.

Cons

  • Still replaces the whole DOM, so images still reload.
  • A fast typist who never pauses sees no update; needs a maximum wait.

Debounce, parse in a worker, patch changed blocks

Send the text to a Web Worker after a 150 ms pause (at most 1 s apart while typing).

The worker returns top-level blocks, each with its source lines and HTML.

The main thread keys blocks by a hash of their source and only replaces the blocks that changed.

Pros

  • The main thread does almost nothing per key.
  • Unchanged blocks keep their DOM: images, videos and diagrams don't reload.
  • Scales to 200,000 characters.

Cons

  • More code: a worker, versioning, and block diffing.
  • Cross-block features (reference links, footnotes) need care.

What we pick: Debounce at about 150 ms with a 1 s maximum wait, and patch only the changed blocks. Start the worker only for documents over about 20,000 characters; below that, markdown-it parses in about a millisecond on a laptop, and a worker's message copying isn't worth it. The block patching is what makes the preview calm, and it helps at every size.

The preview pipeline as a data flow. The Textarea sends the text with version 8 to the Scheduler, which debounces for 150 milliseconds with a 1 second maximum wait. The Scheduler posts the text to markdown-it inside a Web Worker, which returns blocks, each with a key, source lines and HTML. Back on the main thread, the Sanitizer runs DOMPurify on changed blocks only and hands clean HTML to the Block diff, which keeps blocks with the same keys and patches only the changed ones into the Preview DOM.TextareasourceScheduler150 ms, max 1 sWeb Workermarkdown-ittokens + line mapv8postSanitizerDOMPurify per blockblocksBlock diffsame key: keepcleanPreview DOM1 block patchedpatcheverything outside the worker runs on the main thread
Only the worker box runs off the main thread. Each step shrinks the work: the debounce cuts the number of renders, the worker moves parsing away, and the key diff means the sanitizer and the DOM usually touch one block.

Two traps in the worker path. First, replies can arrive out of order if a big parse finishes after a small one, so each request carries a version number and the main thread drops any reply older than the newest one it has applied. Second, DOMPurify needs a DOM, and a worker has none. So the worker only parses; sanitizing happens on the main thread, on changed blocks only, which keeps it cheap.

ts
import MarkdownIt from "markdown-it";
const md = new MarkdownIt({ html: true, linkify: true });

type Block = { key: string; line: number; endLine: number; html: string };

self.onmessage = (e: MessageEvent<{ version: number; text: string }>) => {
  const { version, text } = e.data;
  const t0 = performance.now();
  const env = {};
  const tokens = md.parse(text, env);
  const blocks: Block[] = [];
  const seen = new Map<string, number>();
  let start = 0;
  for (let i = 0; i < tokens.length; i++) {
    if (tokens[i].level !== 0 || tokens[i].nesting === 1) continue;
    // A top-level block ends here: tokens[start..i].
    const [line, endLine] = tokens[start].map ?? [0, 0];
    const src = text.split("\n").slice(line, endLine).join("\n");
    const n = seen.get(src) ?? 0;
    seen.set(src, n + 1);
    const html = md.renderer.render(tokens.slice(start, i + 1), md.options, env);
    blocks.push({ key: `${hash(src)}:${n}`, line, endLine, html });
    start = i + 1;
  }
  self.postMessage({ version, blocks, ms: performance.now() - t0 });
};
preview.worker.ts: parse and split into keyed blocks

Real code would split the lines once instead of per block, and would add a data-line attribute to each block's outer element (next chapters). The key is a hash of the block's source plus a counter for duplicates. When you type in paragraph 40, only paragraph 40's key changes; the other blocks keep their keys, so React (or a hand-written diff) leaves their DOM alone.

Check your understanding · Exercise 5

A 60,000 character docs page with 30 screenshots flickers and stutters while typing. Which changes help? Select all that apply.

Keeping the preview safe

CommonMark passes raw HTML through. So this is valid markdown, and a naive renderer turns it into live HTML on our page: <img src=x onerror="fetch('/api/token').then(...)">. The preview runs on our origin with the person's cookies.

In the preview alone it only attacks the author. But the same text gets posted, and if the posted page renders it the same way, it attacks every reader: a stored XSS. The XSS lesson covers the attack classes; here we decide where the defense lives.

Three kinds of danger hide in user markdown: raw HTML tags and event attributes (script, iframe, onerror), dangerous URLs ([click](javascript:alert(1)), data:text/html,...), and resources that leak information (an image from a tracker's server learns the reader's IP address and when they opened the page).

Decision How do we stop user markdown from running code?

Escape all raw HTML

Parse with markdown-it's html: false (its default). Any tag shows up as literal text.

Pros

  • Simple and very safe for tags.
  • No sanitizer library to load.

Cons

  • People lose useful tags like details, kbd, sub and img with a width.
  • Links still need URL checks.

Allow HTML, then sanitize the output with an allowlist

Render to HTML, then run DOMPurify (on the main thread) or rehype-sanitize (on the tree, works in a worker) with a list of allowed tags, attributes and URL schemes.

Pros

  • Keeps safe HTML that people rely on.
  • Allowlists fail closed: an unknown tag is dropped.
  • DOMPurify is widely used, heavily tested, and tracks browser parsing quirks.

Cons

  • Must run on every render and on the server.
  • The allowlist must be kept in sync between client and server.
  • On a Node server DOMPurify needs a DOM such as an up-to-date jsdom; rehype-sanitize needs none.

Render the preview in a sandboxed iframe

Put the HTML in an iframe with sandbox (no allow-scripts, no allow-same-origin), so even a missed script can't run or reach our cookies.

Pros

  • A strong second wall around the preview.

Cons

  • Does nothing for the posted page.
  • Scroll sync, sizing and styles across the frame get harder.

What we pick: Sanitize the rendered HTML with an allowlist on both sides: DOMPurify in the browser for the preview and the same allowlist on the server for the posted HTML. Sanitize the output, never the input, because the input contains code samples that legitimately show script tags. Add a Content Security Policy as a second wall (the CSP lesson covers it). Use a sandboxed iframe only for the risky extras, like diagrams.

Why not the approach the LLM chat UI guide picks, a syntax tree rendered as React elements with raw HTML turned off? Model output has no reason to contain HTML, so there it's the simplest safe choice. Here people write details and kbd on purpose, so we keep HTML and sanitize it. If you choose the unified pipeline, rehype-sanitize does the same allowlist job on the tree (its default schema follows GitHub's rules) and needs no DOM, so it can even run in the worker.

Before and after sanitizing the parser's HTML output. A script tag is removed. An image with an onerror handler keeps the image but loses the handler, and its source is rewritten to the image proxy. A link to a javascript URL keeps its text but loses the href. A details element is kept as is. A normal https link is kept and gets rel nofollow ugc noopener.parser outputafter the sanitizer<script>steal()</script>removed<img src=x onerror="hack()"><img src="/proxy/x"><a href="javascript:go()"><a>prize</a><details>Logs</details><details>Logs</details><a href="https://x.dev">rel="nofollow ugc noopener"
An allowlist keeps the useful HTML and drops everything it doesn't know. Note the order: parse first, then sanitize the output. Sanitizing the markdown input would mangle code samples that show tags on purpose.
ts
import DOMPurify from "dompurify";

const purify = DOMPurify(window);
purify.addHook("afterSanitizeAttributes", (node) => {
  if (node.tagName === "A") {
    node.setAttribute("rel", "nofollow ugc noopener");
    if (/^https?:/i.test(node.getAttribute("href") ?? "")) node.setAttribute("target", "_blank");
  }
  if (node.tagName === "IMG") {
    const src = node.getAttribute("src") ?? "";
    node.setAttribute("src", toImageProxy(src)); // our proxy, see below
    node.setAttribute("loading", "lazy");
  }
});

export function sanitizeBlock(html: string): string {
  return purify.sanitize(html, {
    USE_PROFILES: { html: true },
    FORBID_TAGS: ["style", "form", "input", "iframe"],
    FORBID_ATTR: ["style"],
  });
}
One sanitize config for preview blocks

A few details from that config. DOMPurify already drops javascript: and other unsafe URL schemes and every on... attribute. It keeps data- attributes by default, which we need for scroll sync. We forbid style so nobody can draw a fake login box over the page.

Links get rel="nofollow ugc noopener". ugc and nofollow tell search engines this is user content, so spammers gain nothing. noopener stops the new tab from controlling ours through window.opener; modern browsers already imply it for target="_blank", and saying it covers older ones.

Images from anywhere, and reader privacy

![](https://tracker.example/pixel.png?u=42) is safe from XSS but not from tracking: every reader's browser asks that server for the image, revealing IP address, time, and sometimes the page through the Referer header. And an http: image on our https: page is mixed content, which browsers upgrade or block.

GitHub solves this with Camo, an open source image proxy: the rendered HTML points at camo.githubusercontent.com with a signed copy of the original URL, so readers' browsers only ever talk to GitHub. We do the same: the server rewrites image URLs to our proxy, signed with an HMAC (a keyed hash) so the proxy can't be used as an open relay.

In the client preview, before anything is posted, we either call the same rewrite or add referrerpolicy="no-referrer" to images. Our own uploads skip the proxy.

The lab runs the whole pipeline on a sample document that includes a raw script tag, an onerror image, a javascript: link and a table that you can make bigger. Watch what the sanitizer removes, how parse time grows with size, and which data-line anchors come out for scroll sync. The timing is measured on your device with the lab's tiny parser, so compare sizes, not libraries.

Table rows
Raw HTML

The sample document, 332 characters with a 3 row table, in sanitize mode. It produced 6 blocks with data-line anchors at lines 0, 2, 4, 6, 8, 14. Sanitizer: blocked javascript: URL; removed script tag; removed onerror handler. Parse time not measured yet.blockdata-lineoutputh10<h1 data-line="0">Rele...paragraph2<p data-line="2">Read ...raw HTML4removedraw HTML6<img data-line="6" src...table, 3 rows8<table data-line="8"><...raw HTML14<details data-line="14...332 chars, 6 blockspress Measure parse time3 things removedHTML: 0.4 KB
Each block keeps the source line it came from in data-line: those are the scroll sync anchors. The lab uses a tiny parser and sanitizer written for this page; real code uses markdown-it and DOMPurify, never regexes.
blocked javascript: URL; removed script tag; removed onerror handler

Check your understanding · Exercise 6

Which inputs must the rendered preview neutralize? Select all that apply.

Keeping source and preview scrolled together

In split view, scroll the source to the middle of a long page and the preview should show the same part. The naive version scrolls by percentage: if the source is 40% down, put the preview 40% down.

It falls apart quickly. A 6 line table might be 30 pixels of source but 500 pixels of preview; three screenshots are three short lines of source but 1,500 pixels of preview. By the middle of a real docs page, a percentage sync is off by whole screens.

Decision How should the two panes stay in step?

Proportional (percentage) sync

preview.scrollTop = ratio * (preview.scrollHeight - preview.clientHeight), with ratio taken from the source.

Pros

  • Five lines of code. Fine for short comments.

Cons

  • Drifts wherever source and preview heights differ: tables, images, code, math.

Anchor mapping with data-line

Every top-level preview block gets data-line with its first source line (from the parser's line map).

Find the two anchors around the source's top line and interpolate between their pixel positions.

Pros

  • Exact at every block boundary, smooth between them.
  • The VS Code markdown preview syncs this way.

Cons

  • Needs source line positions in pixels, which soft wrapping makes tricky in a textarea.
  • Anchor positions must be re-measured when images load or the width changes.

Sync only on caret move

Scroll the preview so the block holding the caret is in view; ignore free scrolling.

Pros

  • Simple and never fights the person.

Cons

  • Scrolling the source to read doesn't move the preview.

What we pick: Anchor mapping with interpolation, in both directions, plus a caret-follow when the person types. The parser already gives us source lines for free, and the result is exact where it matters: at headings, tables and images. Proportional sync is only acceptable for the tabs layout, where the panes are never visible together.

Scroll sync anchors. On the left, the source pane with source lines 0, 12, 40, 60 and 75. On the right, the preview with the tops of their blocks at 0, 600, 1,200, 2,000 and 2,900 pixels. Thin lines join each pair. Source line 52, the top visible line, sits between the anchors for line 40 and line 60, so an arrow maps it to 1,680 pixels in the preview: 1,200 plus 60 percent of 800.source linespreview blocksline 00 pxline 12600 pxline 401,200 pxline 602,000 pxline 752,900 pxline 521,680 px52 is 60% from 40 to 60: 1,200 + 0.6 x 800 = 1,680 px
Each top-level block carries data-line, so anchors are exact at block starts. Between two anchors the offset is interpolated, which keeps a tall table or image from throwing the sync off by whole screens.

The figure shows the idea. Each anchor is a pair: a source line and the top pixel of its block in the preview. When the source's top visible line is 52, and the anchors around it are line 40 at 1,200 pixels and line 60 at 2,000 pixels, line 52 is 60% of the way between them, so the preview scrolls to 1,680 pixels.

ts
type Anchor = { line: number; top: number };

function measureAnchors(preview: HTMLElement): Anchor[] {
  const base = preview.getBoundingClientRect().top - preview.scrollTop;
  return [...preview.querySelectorAll<HTMLElement>(":scope > [data-line]")].map((el) => ({
    line: Number(el.dataset.line),
    top: el.getBoundingClientRect().top - base,
  }));
}

function lineToOffset(anchors: Anchor[], line: number): number {
  let i = anchors.findIndex((a) => a.line > line);
  if (i === -1) return anchors.at(-1)?.top ?? 0;
  if (i === 0) return 0;
  const a = anchors[i - 1], b = anchors[i];
  return a.top + ((line - a.line) / (b.line - a.line)) * (b.top - a.top);
}
Mapping a source line to a preview offset

Which source line is at the top?

In CodeMirror this is one call: view.lineBlockAtHeight(scrollTop). In a textarea it's harder, because long lines wrap, so line 52 is not at 52 * lineHeight.

The trick is a hidden mirror: a div with the same width, font, padding and white-space: pre-wrap, holding one child per source line. Each child's offsetTop is that line's pixel position. The mention input guide uses the same mirror to find the caret. Rebuild it after the debounced render and on resize (a ResizeObserver), never per key.

Avoiding the feedback loop

Setting preview.scrollTop fires a scroll event on the preview, whose handler sets source.scrollTop, which fires a scroll event on the source. The panes jitter or drift as rounding errors bounce back and forth.

Fix it by tracking which pane the person is driving: set driver = "source" on wheel, pointerdown, keydown or focus in the source, and the same for the preview. Only the driver's scroll events cause syncing. Batch the work in requestAnimationFrame so a fast scroll does one update per frame.

Anchors also move when content loads: an image that was 0 pixels tall becomes 400. Watch the preview with a ResizeObserver and re-measure anchors when its height changes. Give uploaded images width and height attributes so the space is reserved before the bytes arrive.

Check your understanding · Exercise 7

After a scroll sync goes live, the panes slowly creep downward on their own. What's the likely cause and fix? Select all that apply.

Code, math, diagrams and pasted images

The base preview is plain HTML. Docs writers want more: colored code blocks, math like $E = mc^2$, and diagrams written as text in a code fence tagged mermaid. Shiki, KaTeX and Mermaid each weigh several times the editor shell, and Prism is small but its language files add up. Shipping them all up front would multiply the editor's size for features most comments never use.

ExtraLibraryHow we load itSafety note
Code highlightingShiki (VS Code's TextMate grammars) or Prism (smaller, regex based)Dynamic import() the first time a fenced block appears; load each language's grammar on demand.The highlighter gets plain code text and escapes it, so its output is trusted. Run it after sanitizing, on the code block only.
MathKaTeXLoad when the text contains $ pairs or a code fence tagged math. renderToString needs no DOM, so it can run in the worker.Keep trust: false (the default) and limits like maxExpand so a crafted formula can't hang the tab.
DiagramsMermaidLoad only when a fence tagged mermaid exists and is near the viewport (an IntersectionObserver).The default securityLevel: "strict" encodes HTML in labels and turns off click handlers; "sandbox" (still marked beta in Mermaid's docs) also renders each diagram in a sandboxed iframe. Never use "loose" for user text.

Two rules make extras play well with the block pipeline. First, a code block's key is the hash of its source, so a highlighted block is only re-highlighted when its code changes. Second, extras render asynchronously into their block: show the plain pre first, then swap in the colored version when the grammar arrives. The preview never waits on the slowest extra.

Shiki's default output sets colors with style attributes, which our sanitizer strips. Either insert highlighted HTML after sanitizing (safe, because the highlighter escaped the code itself) or configure it to emit CSS classes. Never feed highlighter output back through a sanitizer that removes what it added, and never run user HTML through the highlighter path.

Pasting and dropping images

People paste screenshots straight from the clipboard. The naive version uploads, waits, and then inserts the link at the caret. But the upload takes 2 seconds, the person kept typing, and the link lands in the middle of a word. GitHub's comment box shows the fix: insert a placeholder right away, like ![Uploading screenshot.png...](), then replace that exact text when the upload finishes, wherever it has moved to.

Sequence diagram of pasting an image. The user pastes an image into the editor. The editor inserts the placeholder Uploading shot.png at once. The editor calls the host's onUpload with the file, and the uploader sends the bytes to storage. Storage replies 200 with a URL, and the uploader tells the editor, which replaces the placeholder by its exact text. If it fails instead, storage replies 413 or the network drops, the uploader rejects, and the editor removes the placeholder and shows an error with a Retry button.UserEditorUploaderStoragepaste image![Uploading...]onUpload(file)PUT bytes200 + URLreplace by textif it fails:413 or offlinerejectederror + Retry
The placeholder lets the person keep typing during the upload. Replacing it by its text, not by a saved position, puts the link in the right place even after more typing; the failure path cleans up the placeholder so no dead link gets posted.
  • Listen to paste (event.clipboardData.files) and drop (event.dataTransfer.files) on the text box. Also offer an "Attach files" button with a hidden input type="file" for keyboard and mobile users.
  • Check type (image/png, image/jpeg, image/gif, image/webp) and size (say 10 MB) before uploading; reject others with a clear message.
  • Make each placeholder unique: add a counter when two files share a name, since replacement finds it by text.
  • Upload with the host's upload handler. The file uploader guide covers presigned URLs, progress and retries; the editor only needs a promise that resolves to a URL.
  • On success, replace the placeholder with ![screenshot.png](https://cdn.example.com/u/8f3a.png) (alt text from the name; prompt the person to improve it). On failure, replace it with nothing and show an inline error with a Retry button.
  • If the person deleted the placeholder while it uploaded, do nothing on completion: they changed their mind.
  • If the text box has focus when the swap happens, save the person's selection, select the placeholder, call insert, then restore the selection, shifted by the change in length if it sat after the placeholder. If focus is elsewhere (say, the title field), don't steal it: use the setRangeText fallback and accept that this one step isn't undoable.
  • Disable the Submit button (or warn) while uploads are pending, or posts go out with dead placeholders.
State machine for one pasted image. Picked goes to uploading when the file is valid, or to rejected when it is too big or the wrong type. Uploading goes to inserted when the URL arrives and the placeholder is found, to failed on an error, and to skipped when the URL arrives but the placeholder was deleted. Failed goes back to uploading on retry.one machine per placeholderpickeduploadingprogress %insertedlink in textrejectedtype or sizefailedRetry buttonskippedplaceholder gonevalidfoundinvaliderrorretrygone
Every state is something the person can see: a placeholder, a progress note, a link, or an error with Retry. The skipped state matters: if they deleted the placeholder, the finished upload must not put it back.

Check your understanding · Exercise 8

A person pastes two screenshots both named image.png, then keeps typing. Which designs avoid bugs? Select all that apply.

Architecture, data model, drafts and component API

Now put the pieces together. The rule that draws the boundaries: the text box path must never wait on anything. Everything slow (parsing, sanitizing, highlighting, uploading, saving) hangs off the side of it and reports back later.

Architecture of the markdown editor. Inside the main thread zone: the Editor shell on top holds the value, layout, tabs and toolbar, and saves the value to the Draft store in IndexedDB. Below it, the Source surface sends text to the Render scheduler, which posts it to the Parse worker running markdown-it. The worker returns blocks to the Preview, where they are sanitized and patched, and Scroll sync reads anchors from the Preview. Edit commands apply insertText to the Source surface. The Upload manager swaps placeholders through Edit commands and sends files to the host's Upload API.MarkdownEditor (main thread)Editor shellvalue, layout, tabs, toolbarSource surfacetextarea, keysSchedulerdebounce, versionEdit commandsinsert, wrapPreviewsanitize, patchUpload managerplaceholdersScroll syncdata-line anchorsDraft storeIndexedDBWorkerParse workermarkdown-itUpload APIhost onUploadsavetextblocksinsertTextswapanchorsfiles
The left column is the typing path and never waits. Every write into the text goes through Edit commands and insertText; everything slow (parsing, uploads, saving) hangs off to the right and reports back later.
PartResponsibilityWhy it is separate
Editor shellLayout, mode (tabs or split), toolbar, help text, status line.The only part teams restyle; it owns no document logic.
Source surfaceThe textarea (or lazy CodeMirror), key handlers, paste and drop.Swappable behind one small interface: get and set selection, insert text.
Edit commandsinsert, wrap, list continuation, indent. Pure functions of text plus selection, applied through insert.Easy to unit test; reused by toolbar, shortcuts and uploads.
Render schedulerDebounce with max wait, versioning, choose main thread or worker.Keeps timing rules in one place.
Parse workermarkdown-it plus plugins, split into keyed blocks with source lines.Heavy CPU work off the main thread for long documents.
Sanitizer and previewDOMPurify per changed block, patch the DOM, run extras, measure anchors.The security boundary is one function everyone can audit.
Scroll syncAnchors, mirror measurements, driver flag.Only exists in split view; can be dropped for comments.
Upload managerPlaceholders, calls the host's upload handler, swaps text.Talks to the network, so it must be cancellable and isolated.
Draft storeSaves and restores drafts in IndexedDB.Survives crashes, works offline.

Data flows one way. The text box changes the text; the text goes to the scheduler and the draft store; the preview follows later. The only thing that writes back into the text, apart from the person, is an edit command, and every edit command goes through insert, so undo and onChange stay correct.

The end-to-end picture adds the server. Uploads go to storage and come back as CDN URLs. On submit the host posts the markdown string, and the server renders and sanitizes it with the same rules, rewriting outside images to the proxy. Readers get that stored HTML, never the client preview.

End-to-end data flow. In the browser, the Editor holds the markdown string. It sends text to the client Preview and drafts to IndexedDB. It sends pasted files to Upload storage behind a CDN, which returns a URL that goes into the text. On submit, the Host form posts the markdown to the API, which renders and sanitizes it with the same preset and stores it. Outside image URLs are rewritten to the signed image proxy. Readers get the server HTML, and images load through the proxy.BrowserEditormarkdown stringPreviewclient renderIndexedDBdraftHost formsubmit markdownOur serversUpload storage + CDNreturns CDN URLImage proxysigned outside imagesAPI: render + sanitizesame preset, stores HTMLtextdraftfileURLPOSTrewritereaders get the server HTML; images load through the proxy
The only thing that leaves the browser on submit is the markdown string. The client preview is never stored or trusted; the server renders the real HTML with the same preset, so what readers see matches what the author previewed.

The data model

ts
// Component state (and the host's form state through onChange)
type EditorState = {
  value: string;                 // the markdown source: the only real data
  view: "write" | "preview" | "split";
  pendingUploads: Map<string, Upload>; // placeholder text -> upload
};

// Derived, rebuilt any time, never saved
type RenderResult = { version: number; blocks: Block[]; ms: number };
type Block = { key: string; line: number; endLine: number; html: string };
type Anchor = { line: number; top: number };

type Upload = { file: File; status: "uploading" | "done" | "failed"; url?: string; abort: AbortController };

// IndexedDB, store "drafts", key = draftKey
type Draft = {
  draftKey: string;      // e.g. "issue:812:comment:new" or "doc:42"
  value: string;
  baseVersion?: string;  // server version the draft started from
  savedAt: number;       // Date.now()
};

// localStorage: view mode preference only
type Prefs = { view: "split" | "tabs" };
Client-side state, and where each piece lives

Autosaving drafts

Losing a long comment to a closed tab or a crash is the most hated failure in any editor. The component saves the text under a draft key that the host gives it, like issue:812:comment:new. Save 1 second after the last change and on visibilitychange to hidden (the most reliable "leaving" event, especially on phones). Delete the draft when the post succeeds.

localStorage is synchronous and small (around 5 MB per origin), and writing 50 KB on every pause blocks the main thread. IndexedDB is asynchronous, holds far more, and can store many drafts with their timestamps. The IndexedDB lesson covers the API; a tiny wrapper like idb-keyval is enough here. The multi-step form guide uses the same autosave pattern.

Restoring needs one judgment call. When the editor mounts and finds a draft, compare it with the server's value. If the draft's baseVersion matches the server's current version, restore it quietly and show "Restored your unsaved draft" with a Discard button. If the server changed since (someone else edited the doc), don't overwrite either side: show both, or offer a diff, and let the person choose.

Drafts older than 30 days are deleted on startup. The browser may delete them sooner: Safari removes script-written storage, IndexedDB included, for sites without a visit in 7 days of Safari use, as the multi-step form guide notes. A draft is a safety net for crashes and closed tabs, not long-term storage.

Check your understanding · Exercise 9

Which draft behaviors are correct? Select all that apply.

The component API

Many teams embed this editor: issue comments, docs pages, release notes. The API decides whether they can use it without forking it. The component API design lesson covers the general rules; here are the choices specific to a markdown editor.

The biggest one: who decides how markdown renders? If each team passes its own parser plugins and sanitize options, the preview drifts from what the server stores, and one team turning on raw iframe tags opens a hole for everyone.

Decision How do hosts customize rendering?

Free-form props for parser plugins and sanitize options

plugins={[taskLists, footnotes]} and sanitize={{ allowTags: [...] }} on each editor.

Pros

  • Maximum flexibility per screen.

Cons

  • Preview and server render drift apart.
  • Any team can weaken the sanitizer for the whole site.

Named presets shared with the server

A shared package exports presets like comment and docs. Each is a parser config plus a sanitize allowlist.

The editor takes preset="docs"; the server imports the same package.

Pros

  • Preview matches the stored HTML by construction.
  • Security rules live in one reviewed place.

Cons

  • A new feature means a new preset version, not a prop.
  • The server must be able to run the same JavaScript (or a faithful port).

What we pick: Named presets for anything that changes the HTML, and props for everything else (layout, toolbar, upload handler, labels). Teams that need something new add it to the shared preset with a review, which is exactly the friction you want around the sanitizer.

ts
type MarkdownEditorProps = {
  value?: string;                       // controlled
  defaultValue?: string;                // or uncontrolled
  onChange?: (value: string) => void;   // every edit, typed or programmatic
  preset: "comment" | "docs";           // parser + sanitize rules, shared with server
  layout?: "auto" | "tabs" | "split";   // auto: split above 900 px
  surface?: "textarea" | "code";        // "code" lazy-loads CodeMirror 6
  draftKey?: string;                    // enables autosave
  serverVersion?: string;               // for draft conflict checks
  onUpload?: (file: File, opts: { signal: AbortSignal; onProgress: (pct: number) => void })
    => Promise<{ url: string; width?: number; height?: number }>;
  onSubmit?: () => void;                // Ctrl or Cmd+Enter
  toolbar?: ToolbarItem[] | false;
  label: string;                        // accessible name, required
  placeholder?: string;
  maxLength?: number;
};

type MarkdownEditorHandle = {
  focus(): void;
  insert(text: string): void;          // goes through execCommand, so undoable
  getSelection(): { start: number; end: number };
};
MarkdownEditor props
  • onChange gets a string on every edit. Hosts that keep the value in a big form store should not re-render the whole page per key; the editor works uncontrolled too, with defaultValue, and the host reads the value on submit.
  • onUpload returns a promise of a URL and accepts an AbortSignal. The editor never knows about buckets or presigned URLs, so the same editor works with any storage.
  • label is required, not optional: an editor without an accessible name fails audits on every page that uses it.
  • The imperative handle lets the host add text (a saved reply, a quote of another comment) through the same undoable path.

Check your understanding · Exercise 10

A team asks for a prop to allow iframe tags so they can embed videos in release notes. What is a good response? Select all that apply.

Accessibility and performance

A textarea starts out accessible, so the job is mostly not to break it. A preview that changes all the time is the new risk: done badly, a screen reader talks over the person on every pause.

Accessibility

  • The text box has a visible label (or aria-label from the required label prop) and aria-describedby pointing at short help: "Markdown supported. Press Escape then Tab to leave the editor."
  • Write and Preview are real tabs: role="tablist", role="tab" with aria-selected, arrow keys between them, and each panel a role="tabpanel". The tabs guide covers the pattern in full; the keyboard navigation lesson covers roving focus.
  • In split view the preview is a section with aria-label="Preview", so it shows up as a landmark region people can jump to.
  • Do not make the preview an aria-live region. Every debounced render would read out changed content while the person is typing. Use one small polite status region for events that matter: "Image uploaded", "Upload failed", "Draft restored".
  • Toolbar buttons have names ("Bold"), aria-keyshortcuts, and the toolbar is one Tab stop with arrow keys inside.
  • In the comment preset, shift heading levels down so a # Title in a comment becomes an h3, keeping the page's heading outline sane.
  • If you smooth scroll sync, turn smoothing off under prefers-reduced-motion: reduce.

Performance

The metric that matters is Interaction to Next Paint (INP): the time from a key press to the next frame. Google calls 200 ms or less at the 75th percentile good, but an editor should aim far lower: the text box itself should paint within one frame, about 16 ms. The runtime performance lesson covers long tasks and how to find them. Our budget per key press is the key handler (under 1 ms), the onChange call into the host, and nothing else.

CostWhere it runsHow we keep it small
Key handler and edit commandsMain thread, every keyLook only at the current line; no parsing.
Host re-render from onChangeMain thread, every keyUncontrolled mode, or memoize the rest of the form.
ParseWorker over 20,000 characters, else main threadDebounced 150 ms, max wait 1 s; stale versions dropped.
Sanitize and DOM patchMain thread, after each renderOnly changed blocks; usually one block.
Highlight, math, diagramsLazy chunksLoaded on first use; cached by block key.
Anchor measuringMain threadAfter render and on resize, inside requestAnimationFrame, not on scroll.

Two more wins for very long previews. CSS content-visibility: auto on each block lets the browser skip layout and paint for blocks far off screen; set contain-intrinsic-size so the scrollbar stays stable. It makes scroll anchors for skipped blocks estimates, so re-measure when a block comes near the viewport. And measure in production: report render time and document size with each preview update (sampled), so you see the p95 on real devices, not your laptop.

Check your understanding · Exercise 11

A screen reader user says the editor "keeps talking" while they type. What should change? Select all that apply.

Running it in the interview

In 45 minutes you can't cover every chapter in depth. Pick the two or three that the interviewer leans into, and mention the rest in one sentence each. A plan that works:

MinutesWhat to doWhat to say out loud
0 to 5Ask scope questions: document size, raw HTML, server render, images, phones."The saved value is the markdown string. The preview is derived, so it can lag and be rebuilt."
5 to 10Pick the mode and surface. Draw the wireframe: toolbar, text box, preview."Tabs on narrow screens, split above 900 px. A smart textarea, CodeMirror as an opt-in."
10 to 20Draw the architecture and the render pipeline."Typing never waits: debounce 150 ms, worker for big docs, patch only changed blocks."
20 to 28Security."CommonMark allows HTML. Sanitize the output with an allowlist, same rules on the server, CSP as a backstop, proxy images."
28 to 38One or two deep dives the interviewer picks: scroll sync, undo, uploads, drafts.Name the failure first (percent sync drifts, el.value kills undo), then the fix.
38 to 45Component API, accessibility, what you'd measure."Presets shared with the server; no live preview announcements; INP on real devices."

Draw the wireframe first, then the pipeline. Interviewers relax when they see you split the fast path (the text box) from the slow path (everything else) in the first ten minutes; it shows you know where the performance and safety problems will come from.

Think first

Follow-up: "Now two people need to edit the same docs page at once. What changes?"

Think first

Follow-up: "How would you test the sanitizer?"

Check your understanding · Exercise 12

Which statements would make a strong first ten minutes? Select all that apply.

Summary

  • The markdown string is the only real data. The preview is derived, so it can lag, run in a worker, or be rebuilt on the server.
  • Pick the mode by audience and width: Write and Preview tabs on narrow screens, split view above about 900 pixels. Live-preview hybrids are a different product.
  • A smart textarea covers most needs. Route every programmatic edit through execCommand("insertText") so native undo and onChange keep working.
  • Debounce the preview (about 150 ms, 1 s max wait), parse big documents in a worker, and patch only blocks whose source hash changed, so images don't reload.
  • CommonMark allows raw HTML. Sanitize the output with an allowlist on client and server, block javascript: URLs, add rel="nofollow ugc", proxy outside images, and keep a CSP behind it.
  • Sync scrolling with data-line anchors and interpolation, only from the pane the person drives, and re-measure when images load.
  • Insert unique placeholders for uploads and replace them by text; save drafts to IndexedDB by a host-given key, and check the server version before restoring.

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