cf.completefrontend
My progress
SYSTEM DESIGN · #103

Design Gmail

The web client of an email service like Gmail or Outlook: an inbox of thousands of threads, conversations, compose with drafts and undo send, search, and offline mode. The hard parts are a fast list you can select and change in bulk, showing untrusted email HTML safely, drafts that never lose a word, and a local mailbox that stays in sync with the server.

The question

Design the web client of an email service like Gmail. People read their inbox, open conversations, write and send mail with attachments, search years of mail, and organize it with labels, archive, and delete. Many people keep the tab open all day and expect new mail to appear without a reload.

The inbox can hold 100,000 messages. Email arrives from anyone on the internet, so the content is untrusted. Users want keyboard shortcuts, and they want to read and write mail on a train with no signal. Walk me through how you'd build the client: what it loads, what it stores, how it renders mail, and how it stays in sync.

Real-life examples: Gmail on the web, Outlook on the web, Proton Mail, Fastmail, Superhuman

Requirements: the questions that shape an email client

"Design Gmail" is one of the widest questions you can get. An email client is a list app, a document viewer, a text editor, a search engine, and an offline database at the same time. In 45 minutes you can go deep on two or three of these, and touch the rest. The first five minutes decide which ones, and you decide by asking questions whose answers change the design.

Start with size (how much mail, how many labels), then with the people (desktop or phone, one tab or several, how good is their network), then with the features (threads, compose, search, offline), and finally with the hard constraints (security of untrusted content, accessibility). The table gives the answer this guide assumes for each question, and what moves if the interviewer says something else.

QuestionAnswer we assumeWhat it changes
How much mail does one user have?Up to 100,000 messages in about 40,000 threads. 50 to 200 new messages a day.The client can never hold the whole mailbox. Lists are paged and virtualized; search runs on the server.
Threads or single messages in the list?Threads (conversations) by default, like Gmail, with a setting to turn them off.The list row is a thread summary, not a message. Labels and unread live on messages but show on threads.
Folders or labels?Labels. One message can have many. Inbox, Sent, and Spam are just system labels.Archive means removing the Inbox label, not moving a file. Counts are per label.
Which devices?Desktop browsers first. Mobile web works but phone apps are separate products.Keyboard shortcuts and a three-pane layout matter. Touch gestures are a follow-up.
Does the tab stay open all day?Yes. Many people never close it.New mail must arrive by push. Memory must not grow forever. Deploys must not break open tabs.
Is offline required?Read recent mail, write and queue sends, and archive or label while offline.A local copy in IndexedDB, an outbox, and a sync protocol with the server.
What can a message contain?Full HTML from any sender, images, and up to 25 MB of attachments per message.A sanitizer, a sandboxed frame, an image proxy, and resumable uploads.
Undo send and scheduled send?Yes. Undo for 5 to 30 seconds, schedule for any future time.Sending becomes a delayed commit owned by the server, not a click that fires at once.
Wireframe of the email client's main screen. A top bar has a Compose button and a search box holding from:priya has:attachment. A sidebar lists labels: Inbox 3, Starred, Sent, Drafts 1, and Work. The thread list has a toolbar and four rows: Priya Rao, Q3 plan, unread and focused; Tom, me (3), Invoice; Stripe, Your receipt; and Ana, Offsite photos. At the bottom, a snackbar says Archived, Undo, and a docked compose window says New message.Inbox (3) - MailComposefrom:priya has:attachmentInbox 3StarredSentDrafts 1Workselect all, archive, label, morePriya Rao: Q3 planTom, me (3): InvoiceStripe: Your receiptAna: Offsite photosArchived. UndoNew message
Every part maps to a later chapter: the list (virtualization, selection), the snackbar (optimistic changes with undo), the search box (operators), and the docked compose window, which outlives navigation.

Functional requirements

  • Show a mailbox (Inbox, a label, Sent, Drafts, Spam) as a list of threads, newest first, with sender, subject, snippet, date, unread and starred state, labels, and an attachment icon.
  • Select one, many, a shift-click range, or every thread matching a view, and archive, delete, mark read, star, label, or move them in one action, with Undo.
  • Open a thread as a conversation: the messages in order, older ones collapsed, quoted text hidden behind a toggle, attachments listed.
  • Show each message's HTML as the sender meant it, without letting it run scripts, track the reader, or break the page around it.
  • Compose new mail, reply, reply all, and forward in a window that can stay open while the user reads other mail. Rich text, attachments, inline images, recipient autocomplete.
  • Save drafts on their own while the user types, and keep them in sync across tabs and devices.
  • Send with a short undo window, and schedule a send for later.
  • Search all mail with operators like from:, to:, has:attachment, is:unread, and before:, with suggestions while typing.
  • Show new mail in real time and notify the user when the tab is in the background.
  • Work offline for recent mail: read, write, and organize, then sync when the network returns.
  • Full keyboard use with shortcuts, and full screen reader use.

Non-functional requirements

  • Fast start: the inbox shows real rows in under 2 seconds on a warm cache and under 4 seconds cold on a mid-range laptop on a fast 4G link. Largest Contentful Paint (LCP, when the biggest visible element appears) under 2.5 seconds.
  • Fast actions: archive, star, and mark read feel instant (under 100 ms to a visible change), because people do them hundreds of times a day. Interaction to Next Paint (INP, how long the page takes to respond to a click or key) under 200 ms.
  • Smooth scrolling: 60 frames per second through a list of thousands of rows.
  • Safe: no email content can run script in our page, read our cookies, or learn when the user opened it.
  • Never lose writing: a draft survives a crash, a closed tab, and a lost network, losing at most the last few seconds.
  • Correct sync: what one tab or device does shows up in the others within a few seconds, and offline changes are never silently dropped.
  • Accessible: WCAG 2.2 AA, with every action reachable from the keyboard and announced to screen readers.

Out of scope, and why

  • The mail servers themselves (SMTP delivery between servers, spam filtering, storage). We treat them as an API.
  • The rich text editor internals. The rich-text-editor guide covers them; here we only use the editor inside compose.
  • Calendar, chat, and contacts apps that live next to mail in Gmail. They are separate products.
  • End-to-end encryption (like Proton Mail). It moves search and rendering of previews into the client; mention it as a follow-up.

Check your understanding · Exercise 1

Which answers from the interviewer change the client design the most? Select all that apply.

What makes an email client hard

Before picking any technology, name the traits of the problem. Each trait below drives one or more later chapters. If you say these out loud early, the interviewer can follow every later decision back to a reason.

  • Huge and mostly cold. A mailbox of 100,000 messages is gigabytes, mostly attachments. People look at the top 50 threads 90% of the time and search for the rest. So the client holds a small, hot window and asks the server for everything else.
  • Read in bulk, changed in bulk. People triage: they scan 30 subjects, select 12, and archive them in two seconds. Then they open one, reply, and press e to archive it and jump to the next. Actions must feel instant and be undoable, even though each one is a server write.
  • Hostile content. Every message is HTML written by a stranger, possibly an attacker. It can carry scripts, forms that imitate our login page, CSS that covers our toolbar, and invisible images that tell the sender when and where you opened it. No other common app renders this much untrusted markup.
  • Writing must never be lost. A reply can take 20 minutes to write. A crash, a closed tab, or a second tab editing the same draft must not throw that away.
  • Sending is not instant on purpose. Undo send and scheduled send mean a click on Send starts a timer, not a request that delivers mail. The client must show a message as sent while it can still be pulled back.
  • Always open, sometimes offline. The tab lives for days. New mail must arrive by push. The laptop sleeps, wakes on another network, or loses signal on a train. The client must catch up on what it missed without reloading everything.
  • Many writers to one mailbox. The same mailbox changes from this tab, other tabs, the phone app, filters on the server, and other mail clients over IMAP (the standard protocol desktop mail apps use to read a mailbox). The client is never the only source of truth.
  • Power users. Heavy users live on keyboard shortcuts and expect hundreds of actions per hour with no mouse. Screen reader users need the same speed through a different channel.

Compare this with a chat app. Chat is also real-time and offline, but chat messages are short, plain text the app formats itself, and they are almost never edited by many parties. Email is the opposite: long documents, foreign HTML, and many writers changing labels and read state on the same items. That is why the two hardest chapters of this guide, safe rendering and sync, look different from their chat versions.

TraitWhat it forces in the clientChapter
Huge and mostly coldCursor paging, a virtualized list, a bounded cache, server searchInbox list, Search
Bulk changesSelection model, optimistic updates, an undo windowSelection and bulk actions
Hostile contentSanitizer, sandboxed iframe, CSP, image proxyRendering email safely
Writing must not be lostAutosave with debounce, local draft copy, versioned savesCompose
Delayed sendA server-side send queue with a cancel windowCompose
Always open, sometimes offlinePush channel, history-based catch-up, IndexedDB, outboxOffline and sync
Many writersServer is the source of truth, change ids, merge rulesOffline and sync
Power usersShortcuts that never fire while typing, roving focus, live announcementsPerformance and accessibility

Think first

Why is "Archive" in Gmail not the same as moving a file to an Archive folder, and why does it matter for the client?

The inbox list: thousands of threads that scroll at 60 fps

The inbox is the screen people see most. It must show real rows fast, scroll smoothly, and stay correct while new mail arrives and other devices change things. Start with the naive version and watch it break.

The naive version: fetch everything, render everything

Call GET /threads?label=INBOX, get all 4,000 inbox threads, and render one li per thread. Each row is about 15 DOM nodes (checkbox, star, sender, subject, snippet, label chips, date), so that is 60,000 nodes. The JSON is about 2 MB at 500 bytes per thread summary.

On a mid-range laptop, parsing that and building the DOM takes well over a second of main thread work before the first row appears, every scroll repaints a huge tree, and memory grows past what an all-day tab can afford. And it gets worse for the 100,000-thread All Mail view.

Two problems hide in there: how much data to fetch (paging) and how much DOM to build (rendering). Solve them separately.

Decision How should the list load and show more threads?

Numbered pages of 50 (what Gmail does)

Show 50 threads. Older and Newer buttons load the next or previous 50 and replace the list.

The header says "1 to 50 of 2,312".

Pros

  • Tiny DOM, no virtualization needed.
  • Easy to say where you are and to come back to the same page.
  • Works well with keyboard and screen readers: one bounded list.

Cons

  • Triage across page boundaries is clumsy: select all only covers the page.
  • Each page change is a full swap and a request.
  • New mail at the top shifts every page by one, so page 2 can repeat or skip a thread unless the paging is cursor based.

Continuous list with cursor pages and virtualization (what Outlook does)

Load 50 threads at a time with a cursor that points after the last loaded thread.

Render only the rows near the viewport, inside a tall container that has the full height.

Pros

  • Feels like one long list; no page breaks while triaging.
  • DOM stays at 40 to 60 rows whatever the list length.
  • Cursors stay correct when new mail arrives at the top.

Cons

  • Virtualization breaks browser find-in-page and needs care for screen readers.
  • Scroll restoration and keyboard focus on rows that are not in the DOM need extra code.

Load everything into memory, virtualize only the DOM

Fetch all thread summaries once, keep them in memory, render with a virtualizer.

Pros

  • Instant scrolling and client-side sorting and filtering.

Cons

  • 2 MB for the inbox and 50 MB for All Mail. Slow start and high memory for an all-day tab.
  • Every change on the server must patch a huge array.

What we pick: Use cursor pages of 50 with a virtualized list. Numbered pages are a fine and simpler answer, and worth naming because Gmail still uses them, but the continuous list makes bulk triage nicer and the virtualization lesson applies to both. Either way, page with cursors, never with offsets.

The virtualized inbox. On the left, the list as pages of 50 threads: pages 1 and 2 far above keep only their ids, pages 3 and 5 are loaded, page 4 is on screen, page 6 is being fetched with the next cursor, and the rest is not loaded yet. On the right, page 4 zoomed in: 10 overscan rows above, 25 visible rows filling 1,000 pixels, and 10 overscan rows below, about 45 rows in the DOM. Rows are 40 pixels tall, so the spacer height is the row count times 40.page 1: ids onlypage 2: ids onlypage 3: loadedpage 4: on screenpage 5: loadedpage 6: fetchingnot loaded yet10 overscan rows above25 visible rows, 1,000 px10 overscan rows belowzoomfar pages: ids onlypage 6: next cursorDOM: about 45 rowsspacer: rows x 40 px
Paging decides what data is in memory; virtualization decides what is in the DOM. Far pages keep ids so scroll position and selection still work, and the next page is fetched about two screens before the reader gets there.

Why cursors and not offsets

With offset paging, page 2 is offset=50&limit=50. Now three new threads arrive while the user reads page 1. Every thread moves down three places, so page 2 starts with the last three threads of the old page 1: the user sees them twice.

Archive five threads from page 1 instead, and page 2 skips five threads the user never sees. In an inbox that changes all day, offsets are wrong all the time.

A cursor is an opaque token from the server that means "after this exact thread". Inside it, the server encodes the sort key of the last row, for example the last message time plus the thread id to break ties. The next page is "threads older than this key", which stays correct however many threads arrive above or leave.

The Gmail API calls it a pageToken. The client never builds or parses cursors: it only stores the one from the last response and sends it back.

ts
let controller: AbortController | null = null;

async function loadNextPage(view: ListView) {
  if (view.loading || view.nextCursor === null) return;
  controller?.abort();
  controller = new AbortController();
  view.loading = true;
  try {
    const res = await fetch(
      `/api/threads?q=${encodeURIComponent(view.query)}&limit=50` +
        (view.nextCursor ? `&cursor=${view.nextCursor}` : ""),
      { signal: controller.signal },
    );
    const page: ThreadPage = await res.json();
    store.upsertThreads(page.threads);      // one copy per thread
    view.ids.push(...page.threads.map((t) => t.id));
    view.nextCursor = page.nextCursor;      // "" before page 1, null at the end
  } finally {
    view.loading = false;
  }
}
Load the next page. A new list view cancels the old request so a slow page can't land in the wrong list.

How the virtualized list works here

The virtualization lesson covers the general technique; here are the numbers for this list. Email list rows usually have one fixed height per density setting (Gmail offers Default, Comfortable, and Compact); this guide assumes 40 px. Fixed heights are a gift: the offset of row i is just i * rowHeight, so there is no measuring.

A 1,000 px tall viewport shows 25 rows. Render those plus an overscan of 10 rows above and below, so about 45 rows are in the DOM at any time, whether the list has 50 loaded threads or 5,000.

  • Prefetch. When the last rendered row is within about two screens of the end of the loaded ids, call loadNextPage. An IntersectionObserver on a sentinel row, or a check in the scroll handler, both work. At a fast flick of 3,000 px per second this gives about half a second for the request.
  • Bound memory. After 20 pages (1,000 threads), drop the summaries of pages far from the viewport and keep only their ids. Scroll back and they reload. An all-day tab then stays under a fixed budget.
  • Restore scroll. Opening a thread replaces the list. Save the id of the first visible thread and its pixel offset, not just scrollTop, because new mail may have arrived above it by the time the user comes back.
  • New mail while scrolled down. Don't insert rows above the viewport: the list would jump under the reader's eyes. Show a "3 new conversations" button at the top, and insert directly only when the user is already at the top.
  • Total counts. An exact count of a 40,000 thread query is expensive. Show exact numbers for small lists and "many" or an estimate for big ones. Gmail, for example, can show "1 to 50 of many" for a large search.

Check your understanding · Exercise 2

An inbox uses offset paging, 50 threads per page. While the user reads page 1, they archive 4 threads from it and 2 new ones arrive. What happens when they load page 2 with offset=50? Select all that apply.

Selection and bulk actions: instant, correct, and undoable

Triage is the core loop of email: select a few threads, act on them, move on. Two pieces make it work: a selection model that copes with shift ranges and "all 2,312 matching", and actions that change the screen at once and can be undone. The data-table guide covers row selection in general; here we focus on what email adds.

The selection model

Store selection as data, never as checkbox state inside rows. Rows unmount when they scroll out of a virtualized list, so a checkbox that owns its own state forgets it. The store keeps a Set of selected thread ids plus an anchor: the id of the last row the user clicked without Shift.

A Shift click selects every id between the anchor and the clicked row in the view's ordered id list, even rows that are not in the DOM right now. The anchor should follow keyboard focus too, so a keyboard user who selects with x and moves with j and k gets the same ranges as a mouse user.

The tricky case is "select all". The header checkbox selects the 50 loaded rows on screen. Then a banner offers "Select all 2,312 conversations in Inbox". The client has not loaded 2,312 threads and must not try.

So selection has two modes: a set of ids, or a query selection: the view's query plus a set of ids the user unticked afterwards. A bulk action on a query selection sends the query to the server, which runs the change as a background job.

ts
type Selection =
  | { mode: "ids"; ids: Set<string>; anchorId: string | null }
  | { mode: "query"; query: string; excluded: Set<string>; estimate: number };

function isSelected(sel: Selection, id: string): boolean {
  return sel.mode === "ids" ? sel.ids.has(id) : !sel.excluded.has(id);
}

function shiftSelect(sel: Selection & { mode: "ids" }, order: string[], id: string) {
  const from = sel.anchorId ? order.indexOf(sel.anchorId) : -1;
  const to = order.indexOf(id);
  if (from < 0 || to < 0) return sel.ids.add(id);
  const [a, b] = from < to ? [from, to] : [to, from];
  for (let i = a; i <= b; i++) sel.ids.add(order[i]);
}
Selection as a small union. Rows read from it; they never own it.
The selection as a state machine with four states. None, with no ids, goes to Some ids, a set of ids plus an anchor, on a click, x, or shift-click. Some ids goes to Page all, the 50 ids on screen, when the header checkbox is ticked. Page all goes to All matching, the query plus excluded ids, when the banner link is clicked. All matching goes back to None on Escape or after an action runs.Noneno idsSome idsSet of ids + anchorPage all50 ids on screenAll matchingquery + excludedclick, xheader boxbannerEsc, or after an action
Only the last state is not a list of ids: it is the view's query plus the ids the user unticked, so "all 2,312" costs nothing to hold. Bulk actions on it send the query, not ids.

The naive action: wait for the server

The user selects 12 threads and presses Archive. The naive client sends the request, shows a spinner, and removes the rows when the server says yes. At a typical 150 to 300 ms round trip, plus more on a train, every action has a visible pause.

A heavy user archives 200 threads a day one by one with e, so they feel that pause 200 times. Worse, while waiting, the focused row is ambiguous: does j move to the next thread or wait?

Decision How should a bulk action apply, and how should Undo work?

Apply now, Undo sends the inverse

Change the local store at once, send the change to the server right away.

The snackbar's Undo sends a second change that puts back exactly what the first one changed.

Pros

  • Other tabs and devices see the change within seconds.
  • Nothing is lost if the tab closes right after the action.
  • Simple server: every change is a normal write.

Cons

  • Undo must record the exact previous state of each thread, not just the opposite action.
  • A brief window where other devices see the change, then see it undone.

Hold the change in the client until the snackbar closes

Change the local store at once, but only send the request when the 5 to 10 second undo window ends.

Pros

  • Undo is free: drop the pending change, nothing reached the server.

Cons

  • Close the tab or lose power in the window and the action is silently lost.
  • Other tabs show stale state for 10 seconds.
  • Many actions in a row build a queue of held writes that all fire later.

Wait for the server, no optimism

Show a spinner, update when the server confirms.

Pros

  • Never shows a state the server didn't accept.

Cons

  • A pause on every action, hundreds of times a day.
  • Feels broken on slow networks.

What we pick: Apply now and make Undo an inverse change. Archive, label, star, and read state are cheap, reversible writes, so there is no reason to hold them. Holding is the right model for exactly one action: Send, because sent mail can't be pulled back from another server. Even then the server should do the holding, as the chapter on undo send explains.

Optimistic changes as a layer, not an overwrite

The optimistic-updates lesson explains the general idea. In email the twist is that many changes overlap. The user archives thread T, then stars it from search, and a push from the phone marks it read, all within a second. If the archive fails and the client "rolls back" by restoring a snapshot taken before the archive, it also throws away the star and the read mark.

Keep two things instead: the last server state of each thread, and an ordered list of pending changes that the server has not confirmed yet. What the UI shows is the server state with the pending changes replayed on top.

When the server confirms a change, drop it from the list and take the new server state. When a change fails, drop it from the list and replay the rest. Only the failed change disappears; everything else stays.

ts
type LabelChange = {
  id: string;                 // client mutation id, also the idempotency key
  threadIds: string[];
  add: string[];              // label ids, for example ["STARRED"]
  remove: string[];           // for example ["INBOX"] for archive
};

function viewOf(threadId: string): ThreadView {
  const base = serverThreads.get(threadId)!;
  let labels = new Set(base.labelIds);
  for (const c of pending) {
    if (!c.threadIds.includes(threadId)) continue;
    c.remove.forEach((l) => labels.delete(l));
    c.add.forEach((l) => labels.add(l));
  }
  return { ...base, labelIds: [...labels] };
}
The view of a thread is derived, so a failed change can be removed on its own.

Undo is built from the same parts. When an action touches 12 threads, record for each one what the change really changed. If 3 of the 12 were already read and the user marks all 12 as read, the undo must mark only the other 9 as unread. An undo that just sends "mark unread" for all 12 changes three threads the user never touched.

Sequence diagram of archiving with the e key, across User, List view, Store, Sync engine, and Server. The user presses e. The list view adds a pending change to the store that removes the INBOX label. The store re-renders the list without the row, and focus moves to the next row. The store writes an outbox record for the sync engine, which posts modify with mutation id c_93e to the server. On success the server replies 200 with a new history id. On the failure path the server replies 403, the sync engine drops the pending change, the store puts the row back in the list, and the list view tells the user it cannot archive the thread.UserList viewStoreSync engineServerpress eadd -INBOXrow goneoutbox recordmodify c_93e200, historyIdor 403: lockeddrop pendingrow returnscan't archive
The user sees the result in the next frame, before any network call. On failure only this one pending change is removed, so later changes to the same thread (a star, a read mark) stay.
  • Focus after the action. Removing the focused row must move focus to the next row (or the previous one at the end), and announce "12 conversations archived" in a polite live region. Otherwise keyboard and screen reader users are dropped at the top of the page.
  • The snackbar. One at a time. A new action replaces the old snackbar, but the old action stays undoable from a short history (z in Gmail undoes the last action). Keep it on screen at least 5 seconds and longer while it has focus or the pointer is over it, so people who read slowly or use a screen reader have time to reach Undo (WCAG 2.2.1, Timing Adjustable).
  • Idempotency. Each change carries a client mutation id. If the response is lost and the client retries, the server sees the same id and doesn't apply it twice.
  • Query actions. "Archive all 2,312" can't be shown optimistically row by row for threads the client never loaded. Remove the loaded ones, show "Archiving 2,312 conversations" with progress, and refresh the list when the server's job ends.

Check your understanding · Exercise 3

The user selects 10 threads (4 already starred) and stars them all, then a push marks 2 of them read, then they press Undo. What should the client do? Select all that apply.

Threads and messages: the conversation view

A thread (Gmail calls it a conversation) is a group of messages that reply to each other. The message is the real unit of email: it has the sender, the recipients, the body, the attachments, and its own read state and labels. The thread is a view the server builds on top. Mixing up the two causes subtle bugs, so name them clearly.

How the server builds threads

Each email has a Message-ID header, and a reply carries In-Reply-To and References headers listing the ids it answers. The server joins messages that point at each other into one thread, and many servers also compare subjects. Gmail, for example, starts a new conversation when the subject changes or a conversation passes 100 messages.

Threading is a server job: it must be the same in every client and on the phone, so the web client never regroups messages itself. It only reads threadId on each message.

The thread row in the list shows a summary that the server computes: the participants ("me, Priya, Tom (5)"), the subject of the first message, the snippet of the latest one, the newest date, and the union of the messages' labels. A thread is unread if any of its messages is unread. Stars live on messages too: starring a thread from the list usually stars one message, the latest, as Gmail does. These rules belong in one place: the server sends a ready-made summary, so every client agrees.

The naive version: load the whole thread

Open a thread, fetch every message with its full HTML body, and render them all expanded. A long thread with 40 replies can be 2 MB of HTML, because every reply quotes the whole history below it. That is 40 sandboxed frames to create (see the next chapter), most of them showing text the user already read, and a slow open for the one message they care about: the newest.

Decision What should opening a thread load?

Everything at once

One request returns all messages with all bodies and attachment metadata.

Pros

  • One round trip. Expanding an old message is instant.

Cons

  • Megabytes for long threads, slow to parse and render.
  • Bodies the user never expands still cost bandwidth and memory.

Headers first, every body on demand

Return message headers and snippets. Fetch each body when its message is expanded.

Pros

  • Smallest first response.

Cons

  • Even the newest message, which is always shown, needs a second round trip.
  • A waterfall: open, then fetch, then render.

Headers for all, bodies for the ones shown expanded

Return headers and snippets for every message, plus full bodies for unread messages and the latest one.

Older read messages stay collapsed; their body loads when the user expands them.

Pros

  • One round trip shows what the user came to read.
  • Response size grows with unread count, not thread length.

Cons

  • Expanding an old message waits for a request (about 100 to 300 ms). Prefetch on hover hides most of it.

What we pick: Return headers for all messages and bodies only for the ones shown expanded: the unread ones and the latest. This is what Gmail's conversation view does on screen: older read messages appear as one-line summaries, and a long middle section collapses into a count you can click.

Wireframe of an open thread of 12 messages titled Q3 plan. The first message from Priya Rao is one collapsed line. A bar says 8 older messages. Tom Lee's read message is one collapsed line. The latest message, from Priya Rao at 14:05 and unread, is expanded: its body sits in a sandboxed iframe with sanitized HTML, and a dots button labelled Show trimmed content hides the quoted text. A reply bar with Reply, Reply all, and Forward sits at the bottom.Q3 plan (12 messages)Priya Rao: Kicking off Q3 planning8 older messagesTom Lee: Sounds good, one questionPriya Rao, 14:05, unreadsandboxed iframe: sanitized HTML...Show trimmed contentReply, Reply all, Forward
Headers come for all 12 messages, bodies only for the unread and latest ones. The middle collapses into one bar, and quoted history hides behind the dots, because the newest message already repeats it.

Collapsing quoted text

Most of a long reply is quoted history. Showing it in every message makes a 10-message thread ten times longer than it needs to be. Gmail hides it behind a small "..." button labeled "Show trimmed content". To do that, something has to find the quote.

  • HTML mail. Clients mark quotes in known ways: Gmail wraps them in a div with the class gmail_quote, Apple Mail and others use blockquote type="cite", Outlook puts a divider and a "From:" header block above the quote. Match these patterns.
  • Plain text mail. Quoted lines start with > and follow a line like "On Tue, 3 Jun, Priya wrote:".
  • Fallback. Compare the tail of the message with the previous message's body. If they match, the tail is quoted.

Do the detection on the server, at the same time as sanitizing (next chapter), and mark the quoted block with an attribute. The client then only needs a toggle. Doing it on the server means the work happens once per message, not once per open on every device, and the phone app gets the same result.

Very large messages

Newsletters can be hundreds of kilobytes of nested tables. Gmail clips a message whose HTML is larger than about 102 KB and shows a "View entire message" link. Whatever the product reasons, the effect is a ceiling on the work one message can cost the page. Our client can do the same with a size limit in the API response and a separate request for the full body.

  • Prefetch. When keyboard focus rests on a list row for about 150 ms, or the pointer hovers it, fetch that thread's first response in the background. By the time the user presses Enter, it is in the cache.
  • Mark as read. Opening a thread marks it read, through the same optimistic change layer as the previous chapter. Outlook's reading pane has a setting to wait a chosen number of seconds before marking; a short delay avoids marking mail read when a user only flicks past it with the keyboard.
  • Next and previous. After archiving from inside a thread, Gmail opens the next or the previous thread, or goes back to the list, by a user setting. Keep the list's ordered ids in the store so this works without a request.

Check your understanding · Exercise 4

A thread has 12 messages: 9 read, then 3 unread. The user opens it. Which bodies should the first response include? Select all that apply.

Rendering untrusted email HTML safely

This is the chapter that makes an email client different from almost every other app. Every message body is HTML written by someone the user may not know. Some of it is written by attackers who study mail clients for a living. The client must show newsletters with their tables, fonts, and images, and at the same time make sure none of that markup can act on the user's behalf.

What a hostile email can try

AttackExampleWhat it would do in our page
Scriptscript tags, onerror on an image, a javascript: link, an SVG with scriptRun as the user: read mail, send mail, steal the session. This is XSS.
Fake UIA form that looks like our login, or CSS with position: fixed that covers our toolbarPhishing that looks like part of the app.
Style leakA style block with body { display: none } or * { font-size: 40px }Break the layout of the whole app, not just the message.
TrackingA 1 by 1 image with a unique URL per recipientTell the sender that you opened it, when, from which IP address, and roughly where.
Navigationmeta http-equiv="refresh", a base tag, a form that submits on loadSend the page or the frame somewhere else.
Resource abuseA 5 MB table nested 500 levels deepFreeze the main thread on render.

The naive version: innerHTML

Set the message container's innerHTML to the body. Browsers don't run script tags inserted this way, which gives a false sense of safety, but img onerror=... runs at once, CSS in the body restyles the whole app, and every remote image loads from the sender's server with the user's IP address. One of these is a full account takeover. The xss lesson explains why string filtering of HTML always loses; here we need layers.

Decision Where should a sanitized email body be rendered?

Sanitize, then insert into our DOM

Run the body through an allowlist sanitizer such as DOMPurify, then insert it into a div in the app.

Pros

  • Simplest. The message flows with the page; no height measuring.

Cons

  • One sanitizer bug is XSS with the user's full session.
  • Email CSS must be rewritten and scoped by hand or it leaks into the app.
  • The app's CSS leaks into the email too, breaking newsletter layouts.

Sanitize, then render inside a shadow root

Attach a shadow root to the message container and put the sanitized HTML inside.

Pros

  • Styles are scoped both ways, without a separate document.

Cons

  • Same document, same origin: a sanitizer miss still runs script as the user.
  • position: fixed content can still cover the app.

Sanitize, then render in a sandboxed iframe with its own CSP

Put the sanitized HTML in an iframe with srcdoc, a sandbox attribute that does not allow scripts or forms, and a strict Content Security Policy.

Measure its height and size the frame to fit.

Pros

  • Two independent layers: if the sanitizer misses a script, the sandbox still blocks it.
  • Full CSS isolation both ways. Fixed-position content stays inside the frame.
  • Remote loads are limited by CSP to our image proxy.

Cons

  • One frame per expanded message costs memory and a few ms each.
  • Height must be measured and updated, for example when images load.

What we pick: Sanitize on the server, then render in a sandboxed iframe with a strict CSP. Defense in depth matters more here than anywhere else in the app, and the iframe solves CSS isolation for free. The cost, a frame per expanded message, is bounded because the previous chapter expands only unread and latest messages.

The email rendering pipeline. Top row, on the server: the sender's raw HTML goes to the Sanitizer with an allowlist, then to URL rewrite, which points images at the proxy, then to the Mail API, which serves safe HTML. Bottom row: the Mail API's HTML goes into an iframe in the browser with srcdoc, sandbox, and CSP. Image requests from the iframe go to our Image proxy, with no cookies and our IP address, which fetches from the sender's Image host. Notes list the three layers: allowlist sanitizer; sandbox with no scripts or forms; CSP allowing images only through the proxy.Senderraw HTMLSanitizerallowlistURL rewriteimages to proxyMail APIsafe HTMLImage hostsender's serverImage proxyno cookies, our IPiframesrcdoc, sandbox, CSPHTMLimg GETfetchlayer 1: allowlist sanitizer on the serverlayer 2: sandbox, no scripts and no formslayer 3: CSP, images only through the proxy
Each layer covers a failure of another. A sanitizer bug still meets a frame that can't run script, and a tracking pixel still reaches the sender only through a proxy that hides who and where the reader is.

Layer 1: the sanitizer

Sanitize with an allowlist: a list of the tags, attributes, and CSS properties that are allowed, with everything else removed. A blocklist ("remove script") always misses the next trick. Keep layout tags (table, td, div, p, img, a, font, center, since old newsletters still use them), and keep style attributes and style blocks but parse the CSS and drop properties like position: fixed and behavior.

Drop every on* attribute and every form, allow only http, https, and mailto in URLs, and rewrite cid: references (images attached to the mail itself) to attachment URLs. Run it on the server, once per message, so every client is protected and the client never parses the raw body at all. If a client must sanitize (for example a body it received some other way), run DOMPurify in the client too.

Layer 2: the sandboxed frame

ts
function renderBody(container: HTMLElement, safeHtml: string) {
  const frame = document.createElement("iframe");
  frame.setAttribute(
    "sandbox",
    "allow-same-origin allow-popups allow-popups-to-escape-sandbox",
  );
  frame.title = "Message body";
  frame.srcdoc =
    `<meta http-equiv="Content-Security-Policy" content="${EMAIL_CSP}">` +
    `<base target="_blank">` + safeHtml;
  frame.addEventListener("load", () => {
    const doc = frame.contentDocument!;
    const fit = () => (frame.style.height = doc.documentElement.scrollHeight + "px");
    new ResizeObserver(fit).observe(doc.documentElement);
    fit();
  });
  container.replaceChildren(frame);
}
No allow-scripts and no allow-forms. Same origin is kept only so our page can measure the frame.
  • Never combine allow-scripts with allow-same-origin. Together they let the frame's script reach into our page and remove its own sandbox. We keep same origin and drop scripts: the frame can't run any code, and our code can measure it.
  • Why not an opaque origin? The xss lesson drops allow-same-origin or serves content from another domain, and sizes the frame with postMessage. That needs a sizing script inside the frame. Here the frame runs no script at all, so only the parent can measure it, and reading contentDocument requires same origin. Serving bodies from a separate cookie-less domain is a fine upgrade if you can run a sizing script there.
  • Popups are allowed so links open in a new tab, and allow-popups-to-escape-sandbox lets that new tab work like a normal page. Links also get rel="noopener noreferrer" so the opened site can't reach back to our window or learn which mail it came from.
  • No allow-forms: a phishing form inside an email can't submit.
  • No allow-top-navigation: the email can't redirect the whole app.

Layer 3: CSP and the image proxy

A srcdoc frame inherits our page's Content Security Policy, and the meta tag above adds a stricter one just for the email, something like default-src 'none'; img-src https://img-proxy.example.com data:; style-src 'unsafe-inline'; font-src https://img-proxy.example.com; form-action 'none'. Inline styles have to be allowed because email is built on them. form-action must be listed on its own, because it does not fall back to default-src. Everything else is blocked: no scripts, no frames, no connections, no form posts, and images only from our proxy.

The sanitizer rewrites every remote URL (in img src, srcset, and CSS url()) to go through an image proxy on our servers. Gmail has done this since 2013. The proxy fetches the image without the user's cookies and from our IP address, so the sender learns neither who nor where the reader is. It can also cache, resize, and convert images, and refuse anything that is not an image.

The sender may still learn that someone opened the mail, so on top of the proxy, offer a setting to ask before showing external images. Strip known tracking pixels (tiny images from known tracker domains) entirely.

Email
Layers on

Every trick in the Phishing mail is stopped. Now switch off one layer at a time: which tricks does another layer still catch?

Opening the Phishing mail with these layers on: Sanitizer, Sandboxed iframe, CSP, Image proxy. fake sign-in form: removed by the sanitizer. position: fixed: sanitizer dropped it. tracking pixel: via proxy: open seen, not IP.trick in the emailwhat happensfake sign-in formremoved by the sanitizerposition: fixedsanitizer dropped ittracking pixelvia proxy: open seen, not IPgets through: 0 of 3
Only the frame stops style leaks, and only the proxy or the ask setting shows images without leaking the reader's IP address. Every script and form trick is caught by three layers, so one failed layer is never enough for the attacker.

Dark mode for emails

The app has a dark theme, but most emails were designed on white with dark text and hard-coded colors. Painting a white newsletter in a dark app is blinding; inverting it blindly ruins logos and photos. Do it in steps:

  1. If the email declares dark support (a color-scheme meta tag, or @media (prefers-color-scheme: dark) rules in its CSS), set color-scheme: dark in the frame and let its own rules apply.
  2. Otherwise, for simple mail (plain text or mostly text), adjust only near-white backgrounds and near-black text, and leave images and strongly colored blocks alone.
  3. For complex mail, keep it on a light card and give the user a per-message toggle to switch the background, as Outlook on the web does.

Check your understanding · Exercise 5

A bug in the sanitizer lets an img tag with an onerror handler through. What still stops it from running in this design? Select all that apply.

Compose and drafts: never lose a word

Compose in Gmail is a small window docked at the bottom right. The user can keep it open while reading other mail, open three of them, minimize them, or pop one out into its own browser window. That one detail shapes the state design: a compose window is not a page, so its state can't live in a page component that unmounts when the user opens another thread.

  • Compose state lives in a store, keyed by draft id: recipients, subject, the editor document, attachment ids, the reply-to message id, and the save status. The docked window is just a view of it. Navigating the app doesn't touch it.
  • The editor is a rich text editor in a contenteditable element. The rich-text-editor guide covers its model, paste cleaning, and undo. Here it matters that pasted HTML (from Word or a web page) is cleaned on paste with the same allowlist as incoming mail, because our own outgoing mail becomes someone else's untrusted email.
  • The recipient fields are autocompletes with chips. The autocomplete guide covers the widget. Email adds two things: parse a pasted list like Priya Rao <priya@example.com>, tom@example.com into separate chips, and search a cached list of frequent contacts locally first, so the first suggestions show in a few milliseconds, then merge server results.
  • Pop out opens the same draft in a new window. Both windows must not edit the draft at once; see conflicts below.

The naive version: save on every keystroke

Send the whole draft to the server on every input event. A typist at 300 characters per minute fires 5 saves per second, each carrying the full document, maybe 50 KB with a quoted thread. That is 250 KB per second of upload and 18,000 writes an hour for one person, and responses arrive out of order, so an older save can land after a newer one and overwrite it. The opposite naive version, save only when the user clicks Save or closes the window, loses everything on a crash.

Decision When and where should a draft be saved?

Every keystroke to the server

Fire a save request on every change.

Pros

  • Nothing is ever more than one keystroke behind.

Cons

  • Huge write load, out-of-order responses, and a wasted battery on mobile.

Fixed interval to the server

Save every 30 seconds if anything changed.

Pros

  • Predictable load.

Cons

  • Up to 30 seconds of writing lost in a crash.
  • Saves fire mid-word and during idle periods alike.

Local copy on every change, debounced server save with a max wait

Write the draft to IndexedDB on every change (local, usually a few milliseconds).

Save to the server 2 seconds after the user stops typing, and at least every 10 seconds while they keep typing.

One save in flight at a time; each save carries a version number.

Pros

  • A crash loses nothing that reached IndexedDB.
  • About 6 server saves a minute while typing, zero while idle.
  • Versions stop old saves from overwriting new ones.

Cons

  • Two copies (local and server) that must be reconciled on reload.
  • More states to show: saving, saved, offline, conflict.

What we pick: Keep a local copy on every change and save to the server with a debounce plus a max wait. The local copy covers crashes and offline; the debounce keeps the server load low; the max wait bounds the loss if the device itself dies. Gmail's "Saving..." then "Draft saved" label is the visible part of this loop.

State machine of a draft's save status. Saved goes to Dirty on an edit. Dirty goes to Saving after 2 seconds of quiet or 10 seconds of typing. Saving goes back to Saved when the server answers 200 with a new version. Saving goes to Retry when the request fails, offline or a server error, and Retry goes back to Dirty so a backoff timer saves again. Saving goes to Conflict on a 409, where both copies are kept.Savedversion 8Dirtylocal copy onlyRetrybackoffSavingone in flightConflictkeep bothedit2 s quiet, 10 s max200: version + 1failstimer409
Every state is shown to the user as a short status: Saved, Saving, Saved on this device, or a conflict prompt. An edit during Saving just marks the draft dirty again, so one more save follows.

How the save loop works

ts
function onDraftChange(draft: Draft) {
  draft.dirty = true;
  void localDb.put("drafts", draft);           // every change, local
  clearTimeout(draft.timer);
  draft.timer = setTimeout(() => save(draft), 2000);
  draft.firstDirtyAt ??= Date.now();
  if (Date.now() - draft.firstDirtyAt > 10_000) void save(draft);
}

async function save(draft: Draft) {
  if (!draft.dirty) return;                    // a max-wait save already sent it
  if (draft.inFlight) return void (draft.again = true);
  draft.inFlight = true; draft.dirty = false; draft.firstDirtyAt = undefined;
  try {
    const res = await api.putDraft(draft.id, draft.body, draft.version);
    if (res.status === 409) return showConflict(draft, res.serverDraft);
    draft.version = res.version;                // server's new version
  } catch { draft.dirty = true; scheduleRetry(draft); }
  finally {
    draft.inFlight = false;
    if (draft.again) { draft.again = false; void save(draft); }
  }
}
Debounce with a max wait, one save in flight, and a version for each save.
  • Leaving the page. On visibilitychange to hidden (and pagehide), flush the draft with fetch and keepalive: true, which lets the request finish after the page is gone. Keepalive bodies are limited to 64 KB in total, so for a bigger draft rely on the IndexedDB copy and save on the next load. Don't rely on beforeunload: it does not fire reliably when a mobile browser kills a tab.
  • Reload. On start, compare each local draft with the server's version. If the local one is newer and unsaved, upload it. If the server's is newer (edited on the phone), take it.
  • Show status honestly. "Saving...", "Saved", "Saved on this device, will sync when online", "Couldn't save, retrying". A user deciding whether to close the laptop needs the truth.

Conflicts across tabs and devices

The user starts a reply on the laptop, leaves, and edits the same draft on the phone. Back at the laptop, the stale tab wakes up and its debounce fires. Without versions, it overwrites the phone's edits with an old copy.

With versions, every save says "I am editing version 7". If the server is already at version 8, it answers 409 Conflict with its copy, and the client can choose.

Decision What should happen when two places edit the same draft?

Last write wins

Every save overwrites the server copy.

Pros

  • No conflict UI at all.

Cons

  • A stale tab silently destroys newer writing. This is the bug users remember.

Merge the two texts

Merge with operational transformation or a CRDT (the merge algorithms behind shared documents like Google Docs).

Pros

  • Both edits survive.

Cons

  • Heavy machinery for a case that is rare and has one author.
  • Merged prose often reads badly anyway.

Detect with versions, one editor at a time

Within one browser, tabs agree through a Web Lock or BroadcastChannel that only one window edits a draft; others show it read-only with "Open here".

Across devices, versioned saves detect the conflict and the user picks which copy to keep, with the other saved as a separate draft.

Pros

  • Nothing is ever silently lost.
  • Simple: drafts have one author, so conflicts are rare and a choice is acceptable.

Cons

  • A small conflict UI to design and test.

What we pick: Detect conflicts with versions, and keep one editor per draft within a browser. Never merge and never silently overwrite. When in doubt, keep both copies: an extra draft costs the user a click; a lost paragraph costs their trust.

Check your understanding · Exercise 6

The user types for 25 seconds without a pause, then stops. With a 2 second debounce and a 10 second max wait, which statements are true? Select all that apply.

Attachments, undo send, and scheduled send

Pressing Send looks like one action. In a modern email client it is the end of a pipeline: attachments uploaded while the user was still typing, inline images turned into parts of the message, and then a delayed commit that the user can still cancel.

Attachments: upload early, resume on failure

The naive client uploads attachments when the user presses Send, inside the send request. A 20 MB file on a 5 Mbps upload link takes about 32 seconds, so Send hangs for half a minute, and one network blip restarts the whole thing. Instead, start each upload the moment the file is attached, while the user keeps writing, and attach the result to the draft by id. The file-uploads lesson covers the general pattern; email adds a few rules.

  • Resumable. Upload in chunks (for example 5 MB) to an upload session. Google's resumable upload protocol works this way: one request opens a session URL, then chunks are sent with Content-Range, and after a failure the client asks how much arrived and continues from there.
  • Limits. Gmail allows 25 MB of attachments per message and offers a Drive link instead for bigger files. Check size and type before uploading, not after.
  • Send while uploading. If the user presses Send before uploads finish, either block with "Attachments are still uploading" or, better, queue the send to fire when the last upload completes, and keep the progress visible in the outbox.
  • Inline images. A pasted screenshot uploads like an attachment and shows in the editor by URL. When the message is built, the server turns it into a part of the email and the img points at it with a cid: URL, so the recipient sees it without loading anything remote.

Undo send is a delayed commit

Gmail lets the user pick an undo window of 5, 10, 20, or 30 seconds. Once mail reaches another server it can't be pulled back, so the only way to offer Undo is to not send for that long. The design question is who holds the message during the wait.

Decision Who holds an outgoing message during the undo window?

The client holds it

Show "Sending..." with Undo, start a timer in the page, and call the send API when the timer ends.

Pros

  • Undo is trivial: cancel the timer.
  • No server changes.

Cons

  • Close the tab, lose the network, or let the laptop sleep during the window and the mail is never sent, or sends hours later when the tab wakes.
  • Background tabs throttle timers, so a 10 second wait can stretch.

The server holds it

Send at once with sendAt = now + window. The server stores it in a send queue and returns a send id.

Undo calls cancel on that id; the server moves the message back to Drafts.

Pros

  • Closing the tab right after Send still sends.
  • The same queue serves scheduled send: it is just a later sendAt.
  • Other devices see the message as "Sending" in the Outbox.

Cons

  • Undo needs a network round trip and can race the deadline.
  • Needs a server-side queue.

What we pick: The server holds it. A send that silently fails because a tab closed is the worst bug an email client can have. The same send queue gives scheduled send for free, and the client only has to show a timer and call cancel.

Held by
Undo window

The server queue sent the message at 10 s. The client only showed a timer.

Timeline of one send with a 10 second undo window, held by the server queue. The user does not press Undo. Result: sent at 10 s.Send, 0 s10 sdeadlineHolds the messageserver send queueresult:sent at 10 s
The server model survives a closed tab and gives scheduled send for free; its only cost is a cancel that can arrive after the deadline. The client model makes Undo trivial but can lose mail.
  • The race. The user presses Undo at 9.9 seconds of a 10 second window. The cancel request takes 200 ms; the server's queue fired at 10.0. The server answers 409 with "already sent" and the client says so plainly. To make this rare, hide the Undo button a little before the deadline, for example 1 second early.
  • Offline send. With no network, the client can't reach the server queue. It puts the message in the local outbox (next chapters) and shows "Queued, will send when online". Undo then just removes it from the outbox.
  • Scheduled send. The same API with a chosen sendAt in the future. Show the time in the user's time zone, store it in UTC, and list the message under a Scheduled label where it can be edited or cancelled until it fires.
  • Idempotency. The send request carries the draft id and version. If the client retries because the response was lost, the server returns the same send id instead of sending twice.
Sequence diagram of undo send, across User, Client, Send queue, and Recipient. The user presses Send. The client posts send with sendAt now plus 10 seconds. The queue replies with send id s_301. The client shows Sent with Undo for 10 seconds. The user presses Undo at 9.9 seconds, and the client posts cancel s_301. Meanwhile the queue delivers to the recipient at 10.0 seconds. The queue then answers 409 already sent, and the client tells the user the message was already sent.UserClientSend queueRecipientpress SendPOST send, +10 ssendId s_301Sent. UndoUndo at 9.9 sPOST canceldeliver at 10.0 s409 already sentalready sent
The happy path is the same until Undo: if the cancel arrives in time, the queue answers 200 and the message goes back to Drafts. Hiding the Undo button about a second early makes this race rare.

Think first

Gmail shows "Message sent" with an Undo link the moment you press Send, before anything reaches the recipient. Is that honest? What should the UI really say, and when?

Check your understanding · Exercise 7

A user presses Send with a 10 second undo window and closes the laptop lid 3 seconds later. What happens in each model? Select all that apply.

Offline mode and sync: a local mailbox that catches up

Offline support turns the client from a viewer of server data into a small replica of the mailbox. That is a big step, so be clear about what it buys: reading recent mail on a plane, writing replies on a train, archiving in a tunnel, and, as a bonus, a much faster start, because the inbox renders from disk before the network answers. The offline-first lesson covers the general pattern; here are the email-specific choices.

What to keep on the device

DataHow muchWhy
App shell (JS, CSS, fonts)2 to 4 MBCached by the service worker so the app starts with no network. See the service-worker-caching lesson.
Labels and countsA few KBThe sidebar must render first.
Thread summariesLast 30 days, about 3,000 threads, 1.5 MBThe inbox and label lists offline.
Message bodiesSame 30 days, text and sanitized HTML, 50 to 150 MBReading offline. Attachments are skipped unless small or opened before.
DraftsAll open draftsWriting offline, crash safety.
OutboxPending changes and sends, in orderChanges made offline, replayed on reconnect.
Sync stateThe last history idWhere to resume the catch-up.

Store it in IndexedDB (see the indexeddb lesson): object stores for threads, messages, labels, drafts, outbox, and meta, with indexes on label plus date for list queries. Ask for durable storage with navigator.storage.persist() so the browser doesn't evict the mailbox under storage pressure, and check navigator.storage.estimate() to shrink the window on small devices. On a shared computer, offline mail is a privacy risk: make it a setting, and clear it on sign out.

The naive catch-up: refetch everything

When the laptop wakes after a night, refetch every cached list and message. That is 3,000 thread summaries and thousands of bodies to compare, 50 MB or more, for a mailbox where maybe 80 things changed. Repeat it on every wake, every network change, and every tab, and the client spends its life downloading what it already has.

Decision How should the client learn what changed while it was away?

Refetch cached lists

On reconnect, reload each list and every body in the cache.

Pros

  • Simple. No server support needed.

Cons

  • Megabytes per wake for a few changes.
  • Deletions and label removals in lists the client didn't reload are missed.

Poll each list with ETags

Send the list's last ETag (a version tag the server put on the response) in If-None-Match with each list request; the server answers 304 Not Modified if nothing changed.

Pros

  • Cheap when nothing changed.

Cons

  • Still one request per list, and any change resends the whole list.
  • Says nothing about which messages changed.

A change log with a cursor (history ids)

Every change to the mailbox gets an increasing history id on the server.

The client stores the last id it applied and asks for "changes since id N": messages added, deleted, labels added, labels removed.

If N is too old for the server's log, the server says so and the client does a full resync.

Pros

  • Cost grows with what changed, not with mailbox size: 80 changes is a few KB.
  • Catches every kind of change, including deletions.
  • One cursor per mailbox, shared by all lists.

Cons

  • Needs a server change log, and a full-resync path for when the log is too old.

What we pick: Use a change log with a cursor. The Gmail API works exactly this way: every mailbox has a historyId, history.list returns the changes after a given id, and a start id that is too old returns 404, which tells the client to do a full sync. Push (next chapter) only has to say "there is something new", and the client pulls the delta.

Sequence diagram of catching up after a wake, across Tab, Sync engine, IndexedDB, and Server. The tab becomes visible and online. The sync engine posts the outbox records to the server, which replies 200 for each. The engine asks for the history since 51200. The server replies with the changes and history id 51391. The engine applies them in IndexedDB and saves the cursor, and tells the tab to re-render the changed rows. On the other path, the server replies 404 because the id is too old, and the engine runs a full resync of the cached window.TabSync engineIndexedDBServervisible, onlinePOST outbox200 for eachsince 51200changes, id 51391apply, save cursorre-render rowsor 404: too oldfull resync
The outbox goes first, so a resync can never overwrite changes the user made offline. A live push is the same flow started by an SSE event instead of visibilitychange.

The outbox: changes made offline

Every change the user makes (archive, label, mark read, star, save draft, send) becomes a record in an outbox store in IndexedDB before it is sent, with a client mutation id, the change itself, and what it replaced, for undo. The optimistic layer from the bulk actions chapter reads its pending list from this store, so a reload doesn't lose pending changes or their effect on screen. A single sender loop sends records in order, one at a time, and deletes each one only after the server confirms it.

  • Order matters per thread. Archive then unarchive must arrive in that order. Sending strictly in outbox order is the simplest way to guarantee it.
  • Compact before sending. Ten offline star and unstar toggles on one thread collapse into the final state. Fewer requests, same result.
  • Flush when the tab is closed. The Background Sync API lets a service worker send the outbox after the tab closes once the network is back. It is available only in Chromium-based browsers, so treat it as a bonus and always flush on the next start too. See the background-sync lesson.
  • One sender. With three tabs open, only one should send the outbox, or the same change goes out three times (idempotency ids make that safe, but wasteful). Elect a leader with the Web Locks API; the cross-tab-sync lesson shows how.

Conflict rules

While the laptop was offline, the phone also changed things, and new mail arrived. Email is forgiving here because most changes are small and independent. Write the rules down instead of hoping:

CaseRuleWhy
Label added or removed on both sidesApply both, per label. Adding and removing different labels never conflict.Labels are a set; changes to different members are independent.
Same label, opposite changes (read here, unread on phone)The later change on the server wins.Rare, low cost, and the user can fix it in one click.
Archived offline, a new reply arrives in that threadThe thread returns to the Inbox.That's what users expect: the new message has the Inbox label, the old ones don't.
Change to a message deleted on the serverDrop the change and remove the message locally.Delete wins; there is nothing left to label.
Draft edited on both sidesVersion conflict: keep both copies.Writing is never thrown away (compose chapter).
Send queued offlineAlways send; never merge.A send has no conflict, only a delay. Show it in the Outbox until it goes.

Check your understanding · Exercise 9

The client stored history id 51,200 and was offline for two weeks. The server's change log only keeps about one week. What should happen? Select all that apply.

New mail in real time, and notifications

People keep the email tab open all day and expect new mail to appear within seconds. The design of the previous chapter makes this simple: the server only has to tell the client "your mailbox changed, the latest history id is now 51,388". The client then runs the same delta pull it uses after a wake. One code path for catching up, whether the gap was two seconds or two days.

Decision How should the server tell the client that something changed?

Short polling

Ask for changes every 30 to 60 seconds.

Pros

  • Works everywhere, trivial on the server.

Cons

  • New mail shows up to a minute late.
  • Millions of open tabs polling for nothing is heavy load.

Server-Sent Events (SSE)

One long-lived HTTP response; the server writes an event line when the mailbox changes.

EventSource reconnects on its own and sends Last-Event-ID.

Pros

  • One direction is all we need: writes go through normal HTTP requests.
  • Plain HTTP, works with HTTP/2 and standard proxies, automatic reconnect.

Cons

  • Text only, server to client only.
  • On HTTP/1.1 each SSE stream holds one of the browser's 6 connections per host, so share it across tabs.

WebSocket

A two-way connection carrying events both ways.

Pros

  • Lowest overhead per message; good if the app also has chat.

Cons

  • We don't need client-to-server messages here, and WebSockets need their own heartbeats and reconnect logic.
  • Some corporate proxies break them.

What we pick: Use SSE for mailbox change hints, with a slow poll (every few minutes) as a safety net in case the stream silently stalls. If the product already has a WebSocket for chat, reuse it and add a mailbox event type. Either way, the event is only a poke: the data always comes from the delta pull. The choosing-realtime lesson compares the transports in depth.

  • One connection per browser. With five tabs open, elect one leader tab with the Web Locks API to hold the stream, and share events with the others over BroadcastChannel. When the leader closes, its lock is released and another tab takes over.
  • Wake and network changes. On online, on visibilitychange to visible, and on pageshow from the back-forward cache, run a delta pull right away, even before the stream reconnects. That covers the minutes the stream was dead.
  • Apply calmly. New threads at the top only insert directly if the user is at the top of the list (see the inbox chapter). Counts in the sidebar and the tab title ("Inbox (3)") update at once.
  • Deploys. A long-lived tab runs old JavaScript for days. Send the current app version in the stream; if the API changes in a breaking way, show a "New version available, reload" banner, and keep old API versions working for a while.

Notifications

When the tab is in the background, a new message should notify the user. Two browser features cover two cases. While the tab is open but hidden, the page itself can show a notification with the Notification API. When no tab is open, only a service worker can, through the Push API: the server sends a push message through the browser's push service, and the service worker calls showNotification.

  • Ask at the right moment. Never request permission on page load. Ask from a settings toggle or a clear in-page prompt after the user shows interest. Chrome, for example, can switch to a quieter prompt for sites whose requests are often denied, and a denied permission is hard to win back.
  • Notify only for what matters. New mail in the Inbox, or only mail marked important, by setting. Never for mail the user is looking at.
  • Group. Use the notification tag so five messages in the same thread replace one notification instead of stacking five.
  • Click goes to the thread. On notificationclick, focus an existing tab with clients.matchAll and open the thread there, or open a new tab if none exists.
  • Privacy. The notification text shows the sender and subject on the lock screen. Offer a setting to hide the preview.

Check your understanding · Exercise 10

Why does the push event carry only a history id instead of the new message itself? Select all that apply.

Architecture: the pieces and how data flows

With the hard decisions made, the architecture almost draws itself. The rule behind every boundary: the UI never talks to the network directly. It reads from a store and writes changes to the outbox. A sync engine is the only thing that talks to the server, and it does so the same way online, offline, and after a wake.

Architecture of the email client. Inside the browser: List view, Thread view, and Compose at the top. Below them the Mail store, holding threads, labels, and pending changes, next to a Search worker with the local index. Below the store, the Sync engine with the outbox, deltas, and the stream. At the bottom, IndexedDB, next to the Service worker for the shell cache and push. Outside, on the right: the Upload API for resumable files, which Compose uses; the Event stream, which pokes the Sync engine; the Mail API, which the Sync engine sends writes to and pulls deltas from; and the Push service, which wakes the Service worker.BrowserList viewThread viewComposeMail storethreads, pendingSearch workerlocal indexSync engineoutbox, delta, streamIndexedDBService workershell, pushUpload APIresumableEvent streamSSE pokesMail APIwrites, deltasPush servicebrowser'soutboxpokewrites, deltas
Views read the store and write changes; only the sync engine talks to the mail servers. Compose is the one exception, because big uploads go straight to their own resumable endpoint.
PartJobWhy it is separate
App shellSidebar, top bar, routing, code-split screensLoads first and is cached by the service worker; everything else loads into it.
List viewVirtualized thread list, selection, keyboard focusThe hottest screen; it must stay small and fast.
Thread viewConversation, expand and collapse, message framesOwns the sandboxed iframes and their sizing.
Compose managerDocked windows, editor, recipients, uploadsLives across navigation, so it sits beside the router, not under it.
Mail storeNormalized threads, messages, labels, list views, pending changesOne copy of each thread, so one change updates every list.
Sync engineOutbox sender, delta pull, push stream, leader electionThe only network writer; makes offline and online the same path.
Local databaseIndexedDB stores for mail, drafts, outbox, sync stateSurvives reloads and crashes; feeds the store at start.
Service workerApp shell cache, push notifications, background syncRuns when no tab is open.
Search workerLocal index for offline search and suggestionsTokenizing thousands of messages must not block typing.

Why put the outbox between the UI and the server, even online? Because then there is exactly one way a change leaves the device. Online, the outbox empties in milliseconds and the user never notices it; offline, it fills up and empties later. Without it, every action needs two code paths, and the offline one is the one nobody tests.

Data flow for the inbox

Follow one inbox load from a cold start and one archive, end to end. The numbers are for a warm service worker cache and a populated IndexedDB.

  1. The service worker serves the app shell from its cache (about 50 ms). No network needed.
  2. The shell reads labels and the first 50 inbox summaries from IndexedDB and renders them (about 100 to 200 ms). The user sees real rows before any request finishes.
  3. In parallel, the sync engine sends any outbox records, then asks the server for changes since the stored history id.
  4. The delta (a few KB) is applied to IndexedDB and the store; rows that changed re-render. If the delta answer is "too old", the engine fetches the first page fresh.
  5. The push stream opens, so later changes arrive as pokes that trigger the same delta pull.
  6. The user presses e. The list view writes an archive record to the outbox; the store's view of the thread loses the Inbox label at once and the row leaves the list. The sync engine sends the record; the server's answer confirms it and moves the history id forward.
Data flow for the inbox. Reading, left to right: the Mail API sends delta JSON to the Sync engine, which upserts into the Mail store, which gives thread summaries to the List view. Writing, right to left: the List view adds a pending change to the Mail store, the store gives an outbox record to the Sync engine, and the Sync engine posts modify to the Mail API. The Mail store is mirrored to IndexedDB. An SSE stream pokes the Sync engine with a history id. The service worker cache gives the List view its JS and CSS. Notes: first paint comes from IndexedDB, then the delta from the server.Mail APISync engineMail storeList viewdelta JSONupsertsummariespendingoutbox recordPOST modifyIndexedDBmirrorSSEhistory idpokeSW cacheapp shellreads: left to rightwrites: right to leftfirst paint from IndexedDBthen the delta from the server
The same four boxes carry reads and writes, in opposite directions. Nothing on the screen waits for the network: the first rows come from IndexedDB and the shell from the service worker cache.

Check your understanding · Exercise 11

Why does every change go through the outbox even when the user is online? Select all that apply.

Data model: labels, threads, messages, and views

The label model from the chapter on what makes email hard decides the shape of the client store. A message has a set of label ids. Inbox, Sent, Drafts, Spam, Trash, Starred, Unread, and Important are system labels; Work and Receipts are user labels.

A thread's labels are the union of its messages' labels. A mailbox view is a query (label:INBOX, or any search) plus an ordered list of thread ids and a cursor. Nothing is stored twice.

ts
type Label = { id: string; name: string; type: "system" | "user";
  color?: string; unreadCount: number; totalCount: number };

type ThreadSummary = {
  id: string; historyId: string;
  subject: string; snippet: string; participants: string[];
  messageCount: number; lastDate: number;    // ms since epoch, UTC
  labelIds: string[];                        // union over messages
  hasAttachment: boolean;
};

type Message = {
  id: string; threadId: string; labelIds: string[];
  from: Address; to: Address[]; cc: Address[]; date: number;
  snippet: string;
  body?: { html: string; text: string; clipped: boolean }; // loaded lazily
  attachments: { id: string; name: string; size: number; mime: string }[];
};

type ListView = { query: string; ids: string[];
  nextCursor: string | null; loading: boolean };

type OutboxRecord = { id: string; kind: "labels" | "draft" | "send";
  payload: unknown; createdAt: number; attempts: number };
Client entities. Lists hold ids; threads and messages are stored once.
The client data model. A ListView holds a query, thread ids, and a cursor, and points to ThreadSummary records. A ThreadSummary holds the union of its messages' label ids and points to its Messages, which hold label ids and an optional body. Threads and messages point to Labels, which hold counts. OutboxRecords hold pending changes and apply to threads. A Draft has a version and attachments and points to the message it replies to. Notes: the URL holds the view and open thread, memory holds selection and focus, IndexedDB mirrors the store, and the server owns settings and drafts.ListViewquery, ids, cursorThreadSummarylabelIds unionMessagelabelIds, body?OutboxRecordpending changeLabelunread, totalDraftversion, filesidsmessageslabelIdsapplies toreply toURL: view, open threadmemory: selection, focusIndexedDB: mirrors the storeserver: settings, drafts
Lists hold ids, not copies, so one label change on a message updates every list that shows its thread. The optimistic view of a thread is its server copy plus the outbox records that point at it.
DataLives inWhy there
Current view, open thread, search queryURL (hash or path)Back button, reload, and shared links restore it.
Threads, messages, labels, list viewsNormalized store in memory, mirrored to IndexedDBOne copy, fast reads, survives reloads.
Pending changesOutbox in IndexedDB, read by the storeSurvive crashes; drive the optimistic view.
DraftsCompose store, IndexedDB, and the serverNever lose writing; sync across devices.
Selection, keyboard focus, expanded messagesComponent or UI store, memory onlyShort-lived; no reason to persist.
Settings (density, undo window, shortcuts on)Server, cached in localStorageFollow the user across devices; read synchronously at start to avoid a layout flash.

Two details matter in interviews. First, unread counts per label come from the server, not from counting loaded threads, because the client has only a window of the mailbox. When the client changes read state optimistically, it adjusts the count by the same amount and lets the next delta correct it. Second, keep dates as UTC milliseconds and format them at render time with Intl.DateTimeFormat in the user's locale and time zone: "14:05" for today, "3 Jun" for this year, "3/6/2024" for older, and the full date in a tooltip.

Think first

A user archives a thread that is also labeled Work. Which entries in the store change?

Interface: the API between client and server

A REST-style JSON API fits well, close to the real Gmail API. The interesting parts are not the URLs but the rules: cursors on every list, history ids on every change, client mutation ids on every write, and versions on drafts.

http
GET /api/threads?q=label:INBOX&limit=50&cursor=eyJ0Ijo...
200 { "threads": [ThreadSummary...], "nextCursor": "eyJ0Ijo...",
      "estimate": 2312, "historyId": "51388" }

GET /api/threads/t_81f?bodies=unread,latest
200 { "thread": ThreadSummary, "messages": [Message...] }

GET /api/messages/m_7a2/body
200 { "html": "...sanitized...", "text": "...", "clipped": false }

GET /api/history?since=51200
200 { "historyId": "51388", "changes": [
  { "type": "messageAdded", "message": { ... } },
  { "type": "labelsRemoved", "messageId": "m_7a2", "labelIds": ["INBOX"] },
  { "type": "messageDeleted", "messageId": "m_19c" } ] }
404 { "error": "HISTORY_TOO_OLD" }          // do a full resync
Reading: lists, threads, and the change log.
http
POST /api/threads:modify
{ "mutationId": "c_93e", "threadIds": ["t_81f", "t_22a"],
  "addLabelIds": [], "removeLabelIds": ["INBOX"] }
200 { "historyId": "51390" }

POST /api/threads:modifyByQuery            // "all 2,312 matching"
{ "mutationId": "c_94a", "query": "label:INBOX older_than:1y",
  "removeLabelIds": ["INBOX"] }
202 { "jobId": "j_55" }

PUT  /api/drafts/d_12  { "version": 7, "draft": { ... } }
200 { "version": 8 }  |  409 { "serverDraft": { ... }, "version": 9 }

POST /api/drafts/d_12/send  { "version": 8, "sendAt": "2026-10-20T08:40:10Z" }
200 { "sendId": "s_301", "sendAt": "..." }
POST /api/sends/s_301/cancel   200 { "draftId": "d_12" } | 409 { "error": "ALREADY_SENT" }

POST /api/uploads  { "name": "deck.pdf", "size": 18874368 }  200 { "uploadUrl": "..." }

GET  /api/events   (text/event-stream)
data: {"type":"mailbox","historyId":"51391"}
Writing: label changes, drafts, sends, uploads, and the push stream.
  • Errors use one shape: { "error": "CODE", "message": "...", "retryable": true }. The sync engine retries retryable errors with exponential backoff and jitter (a random spread so many clients don't retry at the same moment), and moves others to a "couldn't apply" state the UI can show.
  • Rate limits answer 429 with Retry-After. The outbox sender honors it instead of retrying at once.
  • Idempotency: the server remembers mutationId values for a day, so a retried write is applied once.
  • Bodies are sanitized before they leave the server, with remote URLs already rewritten to the image proxy. The client never sees raw sender HTML.

Why REST and not GraphQL? Either works. The screens need few, stable shapes (a page of summaries, a thread, a delta), so GraphQL's flexibility buys little, while plain HTTP responses are easy to cache in the service worker and easy to replay from the outbox. If the company already runs GraphQL for other products, use it; the rules above stay the same.

Check your understanding · Exercise 12

Which API rules protect against a flaky network? Select all that apply.

Performance: starting a big app fast and staying fast all day

An email client is a large app: list, thread view, rich text editor, search, settings, and a sync engine. Shipped as one bundle it would be well over a megabyte of compressed JavaScript, and on a mid-range laptop parsing and running that alone costs a second or more. Yet the user opens it dozens of times a day and expects the inbox at once. Two facts help: almost every visit is a repeat visit, and the first screen needs only a small part of the app.

Decision How should the first screen get on the page quickly?

Server-render the inbox

The server returns HTML for the first 50 rows, then the app hydrates.

Pros

  • Fast first paint on a cold visit with an empty cache.

Cons

  • Every visit waits for a server round trip and a mailbox query before any pixel.
  • Hydration of a large app delays the first interaction, so e and j don't work yet while the rows are already visible.

Cached shell plus local data

The service worker serves the shell from cache; the inbox renders from IndexedDB; the network updates it afterwards.

Pros

  • Repeat visits show the inbox in a few hundred ms, even offline.
  • No server work before first paint.

Cons

  • The first visit on a new device has nothing cached and is slower.
  • Cached data can be minutes or days old until the delta arrives, so show it as such.

Cached shell, with the first page inlined for cold starts

Same as the cached shell, but when there is no local data, the HTML response embeds the first page of thread summaries as JSON.

Pros

  • Fast repeat visits and a cold start with no extra round trip.

Cons

  • Two start paths to keep working.

What we pick: Cached shell plus local data, with the first page inlined for cold starts. Optimize for the repeat visit, which is nearly every visit, and make the cold start one round trip instead of three.

What loads when

ChunkWhen it loadsRough size (compressed)
Shell, list view, store, sync engineAt start150 to 250 KB
Thread view and frame rendererOn idle after first paint, or on first open50 to 80 KB
Compose and rich text editorOn idle, or when the user presses c or Reply100 to 200 KB
Search worker and local index codeOn first focus of the search box30 to 60 KB
Settings, filters, keyboard helpOn navigationSmall, rarely used

The code-splitting lesson explains the mechanics. Here the key is prefetching on idle: by the time the user presses c, the editor chunk should already be in the cache, because a compose window that takes a second to open feels broken. Hashed file names with Cache-Control: max-age=31536000, immutable let the browser keep chunks forever; a new deploy only changes the names of the chunks that changed.

Staying fast in an all-day tab

  • Keep handlers small. Pressing e should only write the outbox record and update the store; the row leaves the list in the next frame. Index updates, count recalculation, and IndexedDB writes go after the paint. This keeps INP under 200 ms even on slow machines.
  • Bound memory. Cap loaded list pages, keep message bodies in an LRU (least recently used) cache (for example the last 200), and remove the iframes of collapsed or closed messages. Check with performance.memory in Chromium during long test sessions, and watch it in real user monitoring.
  • Lazy images. Avatars and images in emails load with loading="lazy", and the image proxy resizes large images to the frame width.
  • Measure in the field. Report LCP, INP, and CLS from real users, plus app-specific numbers: time to first row, time to open a thread, outbox depth, sync lag (how far behind the server's history id the client is), and sanitizer or frame errors.

Check your understanding · Exercise 13

Which changes most improve the time to see the inbox on a repeat visit? Select all that apply.

Keyboard shortcuts and accessibility

Email power users never touch the mouse: j and k move through the list, o opens, e archives, r replies, c composes, / focuses search, x selects, g then i goes to the Inbox, and ? shows all shortcuts. Screen reader users need the same speed through a different channel. The two must not fight each other.

Shortcuts that never fire at the wrong time

The naive version adds a keydown listener per feature, each checking its own key. Soon c opens compose while the user types the letter c in the search box, two features claim r, and the help dialog lists keys that changed months ago. Put every shortcut in one registry with one listener, and put the guards in that listener once.

ts
const keymap = new Map<string, Command>([
  ["j", next], ["k", prev], ["o", open], ["e", archive],
  ["x", toggleSelect], ["c", compose], ["/", focusSearch], ["g i", goInbox],
]);
let pending = ""; let pendingTimer = 0;

document.addEventListener("keydown", (ev) => {
  if (!settings.shortcutsOn || ev.isComposing) return;
  if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
  const t = ev.target as HTMLElement;
  if (t.closest("input, textarea, select, [contenteditable='true']")) return;
  const key = pending ? `${pending} ${ev.key}` : ev.key;
  const cmd = keymap.get(key);
  if (cmd) { ev.preventDefault(); pending = ""; cmd(); return; }
  const isPrefix = [...keymap.keys()].some((k) => k.startsWith(key + " "));
  pending = isPrefix ? key : "";
  clearTimeout(pendingTimer);
  pendingTimer = window.setTimeout(() => (pending = ""), 1000);
});
One listener, a registry that also builds the ? help dialog, and guards for typing.
  • Never while typing. Skip events from inputs, text areas, and the compose editor, and skip isComposing events so people typing Japanese or Chinese with an input method don't archive mail by accident.
  • Let users turn them off. WCAG 2.1.4 (Character Key Shortcuts) requires that single-character shortcuts can be turned off, remapped to include a modifier key like Ctrl or Alt, or work only when the relevant part has focus. Speech input users can trigger them by accident by saying words. Gmail keeps most of its single-key shortcuts behind a setting that is off by default, and also lets users remap them.
  • Screen readers. In browse mode, screen readers like NVDA and JAWS take single letters for their own navigation (for example h for the next heading), so the page never sees them. Don't fight this with role="application" on the whole app; it breaks reading. The shortcuts work when the screen reader is in focus mode inside the list grid, and every action must also be reachable as a normal button.
  • One source of truth. The ? dialog is built from the same keymap, so it is always correct, and remapping changes both.

Decision Which semantics should the thread list use?

A list of links

A ul with one a per thread; checkboxes and stars are separate tab stops.

Pros

  • Simple, works in browse mode, every row is a real link.

Cons

  • Three or four tab stops per row: 150 Tab presses to cross a page.
  • No standard way to say a row is selected.

A grid with one tab stop

role="grid" with rows and cells, roving tabindex so the list is one tab stop, arrow keys or j and k move between rows, aria-selected on selected rows.

aria-rowcount and aria-rowindex give the true position in a virtualized list.

Pros

  • One tab stop; fast keyboard movement.
  • Selection and position are announced: "row 12 of 2,312, selected".

Cons

  • More code: focus management, roving tabindex, row and cell navigation.
  • Users must switch the screen reader to focus mode inside it.

What we pick: A grid with one tab stop. A triage list is an interactive widget, not a document, and the grid pattern from the WAI-ARIA Authoring Practices gives selection, position, and fast movement. Keep each row's main cell a real link to the thread, so it still works as a link.

  • Live announcements. A polite live region says "Conversation archived. Undo available" and "3 new conversations". Batch announcements so a burst of new mail is one message, not twenty.
  • Focus management. Opening a thread moves focus to its subject heading; going back returns focus to the same row, even if it was re-rendered by the virtualizer. Removing the focused row moves focus to the next one.
  • Email frames. Each message iframe has a title like "Message from Priya Rao", so screen reader users know where they are.
  • Motion and zoom. Respect prefers-reduced-motion for the snackbar and compose window animations, and make the layout work at 200% zoom with the reading pane below the list instead of beside it.

Check your understanding · Exercise 14

A screen reader user in browse mode presses e on the inbox. Nothing is archived. Why, and what is the right fix? Select all that apply.

Running it in the interview

You can't say all of this in 45 minutes, and you shouldn't try. The interviewer wants to see you find the hard parts, make reasoned choices, and go deep where it counts. Here is one way to spend the time.

MinutesWhat you doWhat to draw or say
0 to 5Scope questions: mailbox size, threads or messages, labels, offline, untrusted HTML, undo send"I'll go deep on the inbox with bulk actions, rendering email safely, and compose with drafts and undo send."
5 to 10ArchitectureThe boxes: shell, list, thread view, compose, store, outbox, sync engine, IndexedDB, service worker. "UI reads the store and writes the outbox."
10 to 18Inbox list and bulk actionsCursor pages, virtualization numbers, selection as data, optimistic layer with undo as an inverse change.
18 to 26Rendering untrusted HTMLThe three layers: server sanitizer, sandboxed iframe with CSP, image proxy. Why not innerHTML.
26 to 34ComposeLocal copy plus debounced save with max wait, versions and 409, server-held undo send.
34 to 40Sync and real-timeHistory id deltas, push as a poke, outbox order, conflict rules.
40 to 45Performance, accessibility, wrap-upCached shell, code splitting, grid semantics, shortcuts that can be turned off.

Draw the architecture early and keep pointing at it. When a follow-up comes, place it on the drawing first ("that's in the sync engine") and then answer. This shows that the design holds together, which is what separates senior answers from a list of good ideas.

Follow-up questions to expect

Think first

How would end-to-end encryption, like Proton Mail, change this design?

Think first

A user selects "all 80,000 conversations" in All Mail and deletes them. What does the client do?

Think first

An email shows a fake "Your password expired, sign in" box styled exactly like our app. How does the design help?

Check your understanding · Exercise 15

You have 10 minutes left and haven't covered offline. What is the best use of the time? Select all that apply.

Summary

  • An email client is a list app, a viewer of hostile documents, an editor, a search engine, and an offline replica at once. Pick two or three to go deep on, and say which.
  • Page the inbox with cursors, never offsets, and virtualize fixed-height rows so the DOM stays around 45 rows whatever the mailbox size.
  • Store selection as data with an anchor for shift ranges, and support a query selection for "all 2,312 matching" without loading them.
  • Apply label changes optimistically as a layer of pending changes on top of server state, and make Undo the exact inverse of one change.
  • Render email in layers: an allowlist sanitizer on the server, a sandboxed iframe with no scripts or forms and its own CSP, and an image proxy that hides the reader from trackers.
  • Save drafts locally on every change and to the server with a debounce plus a max wait; use versions to detect conflicts and never silently overwrite writing.
  • Undo send and scheduled send are one server-side send queue with a sendAt time. A client-held timer loses mail when the tab closes.
  • Sync with a history id cursor: push only pokes, the client pulls the delta, and a too-old cursor triggers a full resync after the outbox is flushed.
  • Every change leaves the device through one outbox, so online and offline are the same code path.
  • Single-letter shortcuts must never fire while typing, must be possible to turn off, and must never be the only way to act.

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 5

References

Concepts behind it

Try next