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.
| Question | Answer we assume | What 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. |
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, andbefore:, 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
eto 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.
| Trait | What it forces in the client | Chapter |
|---|---|---|
| Huge and mostly cold | Cursor paging, a virtualized list, a bounded cache, server search | Inbox list, Search |
| Bulk changes | Selection model, optimistic updates, an undo window | Selection and bulk actions |
| Hostile content | Sanitizer, sandboxed iframe, CSP, image proxy | Rendering email safely |
| Writing must not be lost | Autosave with debounce, local draft copy, versioned saves | Compose |
| Delayed send | A server-side send queue with a cancel window | Compose |
| Always open, sometimes offline | Push channel, history-based catch-up, IndexedDB, outbox | Offline and sync |
| Many writers | Server is the source of truth, change ids, merge rules | Offline and sync |
| Power users | Shortcuts that never fire while typing, roving focus, live announcements | Performance 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?
In a label model, archive only removes the Inbox label from the messages in the thread. The thread still has its other labels and still shows under All Mail and under each of those labels.
For the client this means one action changes several lists at once: the thread leaves the Inbox list, its Inbox unread count drops, and it stays in the Work label list unchanged. If the client stored lists as separate copies of the thread, it would have to find and patch each copy. Storing the thread once, and lists as ordered ids plus a label filter, makes the change one write. The data model chapter builds on this.
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?
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.
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.
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;
}
}
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. AnIntersectionObserveron 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.
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]);
}
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?
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.
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] };
}
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.
- 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 (
zin 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?
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.
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
divwith the classgmail_quote, Apple Mail and others useblockquote 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
| Attack | Example | What it would do in our page |
|---|---|---|
| Script | script tags, onerror on an image, a javascript: link, an SVG with script | Run as the user: read mail, send mail, steal the session. This is XSS. |
| Fake UI | A form that looks like our login, or CSS with position: fixed that covers our toolbar | Phishing that looks like part of the app. |
| Style leak | A style block with body { display: none } or * { font-size: 40px } | Break the layout of the whole app, not just the message. |
| Tracking | A 1 by 1 image with a unique URL per recipient | Tell the sender that you opened it, when, from which IP address, and roughly where. |
| Navigation | meta http-equiv="refresh", a base tag, a form that submits on load | Send the page or the frame somewhere else. |
| Resource abuse | A 5 MB table nested 500 levels deep | Freeze 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?
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.
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
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);
}
- Never combine
allow-scriptswithallow-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-originor serves content from another domain, and sizes the frame withpostMessage. That needs a sizing script inside the frame. Here the frame runs no script at all, so only the parent can measure it, and readingcontentDocumentrequires 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-sandboxlets that new tab work like a normal page. Links also getrel="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.
Every trick in the Phishing mail is stopped. Now switch off one layer at a time: which tricks does another layer still catch?
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:
- If the email declares dark support (a
color-schememeta tag, or@media (prefers-color-scheme: dark)rules in its CSS), setcolor-scheme: darkin the frame and let its own rules apply. - 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.
- 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
contenteditableelement. 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.cominto 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?
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.
How the save loop works
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); }
}
}
- Leaving the page. On
visibilitychangeto hidden (andpagehide), flush the draft withfetchandkeepalive: 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 onbeforeunload: 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?
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
imgpoints at it with acid: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?
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.
The server queue sent the message at 10 s. The client only showed a timer.
- 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
409with "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
sendAtin 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.
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?
It is honest enough, because from the user's point of view the decision is made and the system now owns delivery. What matters is that the system really does own it: the server has the message in its queue, so closing the tab can't lose it.
If the client could not reach the server (offline), the UI must say something different, like "Queued" with an Outbox count, because the message lives only on this device. Showing "Sent" for a message that is still only in IndexedDB is a lie that users discover when the reply never comes.
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.
Search: operators, instant suggestions, and offline results
Gmail's original pitch was "search, don't sort". For many people search is the main way to find old mail, and folders are an afterthought. The client's job is to make the query easy to write, show useful suggestions as the user types, and show results fast, including when offline.
The query language
Gmail search supports operators: from:priya, to:me, subject:invoice, has:attachment, filename:pdf, is:unread, is:starred, label:work, after:2025/01/01, older_than:2y, larger:10M, and free words. Search runs on the server, which has the full index, so the client doesn't need to understand every operator.
But it helps to parse the query lightly in the client too, for three reasons. It turns operators into chips the user can remove. It powers the search options form (From, To, Has the words, Date within), which writes the query for people who don't know the syntax. And it runs the same query against the local cache when offline.
type Term = { op: string | null; value: string; negate: boolean };
function parseQuery(q: string): Term[] {
const terms: Term[] = [];
// matches -from:"Priya Rao" has:attachment invoice "exact words"
const re = /(-)?(?:(\w+):)?("([^"]*)"|\S+)/g;
for (const m of q.matchAll(re)) {
terms.push({
negate: m[1] === "-",
op: m[2]?.toLowerCase() ?? null,
value: m[4] ?? m[3],
});
}
return terms;
}
Decision Where should search run?
What we pick: Server first, with a local index for suggestions and offline. Be explicit in the UI: "Showing results from mail on this device. Connect to search all mail." Results that silently miss old mail are worse than no results.
Suggestions while typing
- Local first. Recent searches, contacts that match the typed prefix ("pri" shows Priya Rao and a
from:priya@example.comchip), and labels show in under 16 ms from memory. - Server next. After a 150 ms debounce, ask the server for top matching threads. Cancel the previous request with
AbortControllerwhen a new keystroke arrives, and cache responses by query so backspacing is instant. The autocomplete guide covers this loop in full. - Keep the URL in sync. The submitted query goes in the URL (Gmail uses
#search/followed by the query), so Back returns to the results and the search can be bookmarked. - Results reuse the list. A search result is just another list view with a query instead of a label. Selection, bulk actions, and virtualization work unchanged. This is a strong reason to model every mailbox view as "a query plus a cursor".
AbortController when a newer keystroke arrives, so a slow old answer can't replace a newer one.The offline index
The offline index covers the cached window only. Build it in a Web Worker so tokenizing thousands of messages never blocks typing. Index the sender, recipients, subject, and the plain-text version of the body (the server can send a stripped text version alongside the HTML, which avoids parsing HTML on the device).
Store postings (word to message ids) in IndexedDB so the index survives reloads, and update it incrementally as sync adds and removes messages. For 5,000 cached messages with about 300 words each, the index is a few megabytes: fine on a laptop, worth a size cap on phones.
Check your understanding · Exercise 8
Which reasons justify parsing search operators in the client, when the server runs the search? 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
| Data | How much | Why |
|---|---|---|
| App shell (JS, CSS, fonts) | 2 to 4 MB | Cached by the service worker so the app starts with no network. See the service-worker-caching lesson. |
| Labels and counts | A few KB | The sidebar must render first. |
| Thread summaries | Last 30 days, about 3,000 threads, 1.5 MB | The inbox and label lists offline. |
| Message bodies | Same 30 days, text and sanitized HTML, 50 to 150 MB | Reading offline. Attachments are skipped unless small or opened before. |
| Drafts | All open drafts | Writing offline, crash safety. |
| Outbox | Pending changes and sends, in order | Changes made offline, replayed on reconnect. |
| Sync state | The last history id | Where 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?
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.
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:
| Case | Rule | Why |
|---|---|---|
| Label added or removed on both sides | Apply 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 thread | The 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 server | Drop the change and remove the message locally. | Delete wins; there is nothing left to label. |
| Draft edited on both sides | Version conflict: keep both copies. | Writing is never thrown away (compose chapter). |
| Send queued offline | Always 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?
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, onvisibilitychangeto visible, and onpageshowfrom 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
tagso five messages in the same thread replace one notification instead of stacking five. - Click goes to the thread. On
notificationclick, focus an existing tab withclients.matchAlland 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.
| Part | Job | Why it is separate |
|---|---|---|
| App shell | Sidebar, top bar, routing, code-split screens | Loads first and is cached by the service worker; everything else loads into it. |
| List view | Virtualized thread list, selection, keyboard focus | The hottest screen; it must stay small and fast. |
| Thread view | Conversation, expand and collapse, message frames | Owns the sandboxed iframes and their sizing. |
| Compose manager | Docked windows, editor, recipients, uploads | Lives across navigation, so it sits beside the router, not under it. |
| Mail store | Normalized threads, messages, labels, list views, pending changes | One copy of each thread, so one change updates every list. |
| Sync engine | Outbox sender, delta pull, push stream, leader election | The only network writer; makes offline and online the same path. |
| Local database | IndexedDB stores for mail, drafts, outbox, sync state | Survives reloads and crashes; feeds the store at start. |
| Service worker | App shell cache, push notifications, background sync | Runs when no tab is open. |
| Search worker | Local index for offline search and suggestions | Tokenizing 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.
- The service worker serves the app shell from its cache (about 50 ms). No network needed.
- 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.
- In parallel, the sync engine sends any outbox records, then asks the server for changes since the stored history id.
- 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.
- The push stream opens, so later changes arrive as pokes that trigger the same delta pull.
- 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.
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.
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 };
| Data | Lives in | Why there |
|---|---|---|
| Current view, open thread, search query | URL (hash or path) | Back button, reload, and shared links restore it. |
| Threads, messages, labels, list views | Normalized store in memory, mirrored to IndexedDB | One copy, fast reads, survives reloads. |
| Pending changes | Outbox in IndexedDB, read by the store | Survive crashes; drive the optimistic view. |
| Drafts | Compose store, IndexedDB, and the server | Never lose writing; sync across devices. |
| Selection, keyboard focus, expanded messages | Component or UI store, memory only | Short-lived; no reason to persist. |
| Settings (density, undo window, shortcuts on) | Server, cached in localStorage | Follow 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?
Only the messages of that thread: each loses the INBOX label id. The thread summary's label union is recomputed. The Inbox list view drops the id; the Work list view still contains it, unchanged. The Inbox label's counts go down by one if the thread was unread.
Because lists are queries over one copy of each thread, no other list needs patching by hand. That is the payoff of normalizing.
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.
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
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"}
- 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
429withRetry-After. The outbox sender honors it instead of retrying at once. - Idempotency: the server remembers
mutationIdvalues 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?
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
| Chunk | When it loads | Rough size (compressed) |
|---|---|---|
| Shell, list view, store, sync engine | At start | 150 to 250 KB |
| Thread view and frame renderer | On idle after first paint, or on first open | 50 to 80 KB |
| Compose and rich text editor | On idle, or when the user presses c or Reply | 100 to 200 KB |
| Search worker and local index code | On first focus of the search box | 30 to 60 KB |
| Settings, filters, keyboard help | On navigation | Small, 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
eshould 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.memoryin 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.
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);
});
- Never while typing. Skip events from inputs, text areas, and the compose editor, and skip
isComposingevents 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
hfor the next heading), so the page never sees them. Don't fight this withrole="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?
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
titlelike "Message from Priya Rao", so screen reader users know where they are. - Motion and zoom. Respect
prefers-reduced-motionfor 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.
| Minutes | What you do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope 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 10 | Architecture | The boxes: shell, list, thread view, compose, store, outbox, sync engine, IndexedDB, service worker. "UI reads the store and writes the outbox." |
| 10 to 18 | Inbox list and bulk actions | Cursor pages, virtualization numbers, selection as data, optimistic layer with undo as an inverse change. |
| 18 to 26 | Rendering untrusted HTML | The three layers: server sanitizer, sandboxed iframe with CSP, image proxy. Why not innerHTML. |
| 26 to 34 | Compose | Local copy plus debounced save with max wait, versions and 409, server-held undo send. |
| 34 to 40 | Sync and real-time | History id deltas, push as a poke, outbox order, conflict rules. |
| 40 to 45 | Performance, accessibility, wrap-up | Cached 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?
The server can no longer read message bodies, so everything it did for us moves to the client: sanitizing HTML, finding quoted text, building snippets, and above all search. Server search over encrypted bodies isn't possible, so the local index becomes the only full-text search and must cover much more mail, which means more storage and a careful index build in a worker.
Keys need storage too: a private key encrypted with the user's password, unlocked in memory after sign-in, never written in plain form. Previews in push notifications can't show the subject unless the service worker can decrypt, which is a trade-off to discuss.
Think first
A user selects "all 80,000 conversations" in All Mail and deletes them. What does the client do?
It sends one query-based request with a mutation id and gets a job id back. It removes the loaded rows at once, shows "Deleting 80,000 conversations" with progress from the job (polled or pushed), and refreshes counts when the job ends.
Undo becomes "restore from Trash" for the same query, which the server also runs as a job. The client never loads or lists 80,000 ids. Mention a confirmation dialog, because this is the one bulk action where a mistake hurts.
Think first
An email shows a fake "Your password expired, sign in" box styled exactly like our app. How does the design help?
The sandboxed frame has no allow-forms, so the form can't submit, and the sanitizer removes form elements before that. The CSP's form-action 'none' is a third stop, and with no scripts the fake box can't send keystrokes anywhere either. Any link opens a new tab with the attacker's real address visible.
The frame also contains the fake UI: it can't cover our toolbar or appear outside the message, so it looks out of place. On top, the server's spam and phishing checks add a warning banner above suspicious mail, which the client renders in our own trusted UI, outside the frame.
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
sendAttime. 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.
References
- Gmail API: synchronizing clients with Gmail Full and partial sync with history ids, including the 404 for an expired id.
- Gmail API: push notifications Server-side push through Cloud Pub/Sub. Each notification carries only the mailbox and a history id, as a poke to pull changes.
- Gmail API: managing threads How messages are grouped into threads.
- Gmail blog: images now showing (2013) Why Gmail serves external images through its own proxy.
- MDN: the iframe element and its sandbox attribute Every sandbox token and the warning about combining scripts with same origin.
- MDN: CSP form-action Why form posts need their own directive: it does not fall back to default-src.
- DOMPurify A widely used allowlist HTML sanitizer.
- WAI-ARIA Authoring Practices: grid pattern Keyboard and roles for an interactive grid like the thread list.
- Understanding WCAG 2.2: character key shortcuts Why single-letter shortcuts must be possible to turn off or remap.
- MDN: Web Locks API Electing one tab to hold the push stream and send the outbox.
- web.dev: the offline cookbook Service worker caching strategies for the app shell.
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.