Frontend System Design: Email Client (Gmail)

interviewOctober 19, 2026Β· 11 min read

"Design an email client" is the heavyweight final boss of frontend system design loops. The junior answer is "a list of emails on the left, the selected one on the right." The senior answer knows Gmail is really an offline-first sync engine wearing an email UI: it works on a plane, marks ten thousand threads read without a flicker, undoes a sent email for five seconds, and keeps your scroll position when you come back from a thread. Email has no algorithmic feed to hide the hard parts β€” everything is exposed.

An email client is four systems stacked: a data model problem (what is the unit β€” message or thread?), a rendering problem (10,000 threads, three panes, every screen size), a sync problem (optimistic local truth reconciled against a server that keeps changing underneath you), and a compose problem (a writing surface that survives navigation and can be un-sent). Let's design all four.

Requirements β€” the questions that split junior from senior

Before drawing anything, ask:

  • Read-only or full client? Can you compose, reply, archive, label, delete? Undo send? Each verb adds a state machine.
  • Threads or flat messages? Gmail's core product insight is the conversation. This single decision reshapes the data model, unread counts, and the UI.
  • Scale? How many threads does a heavy user have? (Assume 10k–50k; that kills any "fetch all and filter in memory" answer.)
  • Offline? Must read/compose/archive work on a plane and sync later? Gmail and Superhuman both say yes β€” this is where most candidate answers collapse.
  • Realtime vs refresh? Does new mail push in (WebSocket / FCM-style push), or arrive on poll? What's the acceptable delay?
  • Labels or folders? Folders are single-parent (IMAP). Labels are many-to-many (Gmail). It changes filtering, counts, and archive semantics.
  • Undo send? It's not a server feature β€” it's a client-side design decision, and interviewers love watching you discover that.

I'll assume the full production version: threads, labels, compose with undo send, offline-first, realtime push, 10k+ threads, keyboard-first.

The naive answer β€” and exactly where it dies

// The answer everyone gives first
function MailApp() {
  const [emails, setEmails] = useState([]);

  useEffect(() => {
    fetch('/api/emails').then(r => r.json()).then(setEmails);
  }, []);

  const [selectedId, setSelectedId] = useState(null);

  return (
    <div className="split">
      <EmailList emails={emails} onPick={setSelectedId} />
      {selectedId && <EmailView email={emails.find(e => e.id === selectedId)} />}
    </div>
  );
}

Four named failure modes:

  1. Unbounded DOM β€” rendering 10,000 list rows is hundreds of thousands of DOM nodes. The tab dies before the data does.
  2. Server truth as local state β€” every mark-as-read is a round trip; the list freezes on flaky networks, and there's no offline story at all.
  3. Message-as-unit β€” a reply chain renders as N separate rows; unread counts and archive semantics fight the user's mental model of "the conversation."
  4. Detail-pane navigation β€” clicking a thread blows away list state (scroll, selection, filters) because the list unmounted. Gmail never loses your scroll; this design loses it on every click.

Data model β€” the thread is the aggregate root

The load-bearing decision. Model it like Gmail: the thread is the unit users see, select, count, and archive; messages belong to a thread; labels are many-to-many on threads.

// Normalized store shape (Redux/Zustand-style, keyed by id)
const db = {
  threads: {
    t_901: {
      id: 't_901',
      messageIds: ['m_1', 'm_2', 'm_3'],   // ordered by date
      labels: ['INBOX', 'work'],
      unreadCount: 1,                       // derived, cached
      snippet: 'Sure β€” let's ship it Friday…',
      lastActivityAt: 1760880000000,
      // sync bookkeeping:
      updatedAt: 1760880100000,
    },
  },
  messages: {
    m_1: { id: 'm_1', threadId: 't_901', from: 'a@x.com', body: '…', sentAt: … },
    m_2: { … },
  },
  lists: {
    // list = query result, not data β€” store only id order + cursor
    inbox: { ids: ['t_901', …], nextCursor: 'cur_42', syncToken: 'hist_8712' },
  },
};

Three deliberate choices:

  • Threads in, messages referenced. Unread count, archive, and selection all act on one object. A reply arrives β†’ bump the thread, not the row count.
  • Lists store identity, not content. The inbox is a query result β€” an ordered array of thread ids plus a pagination cursor and a sync token. The data lives once, in threads.
  • Server order is the source of truth. Gmail's list order is "thread with most recent activity first," and a new reply moves the whole thread to the top. Your local sort must mirror the server's, or optimistic updates will fight it.

Labels-as-strings with special semantics (INBOX, TRASH, UNREAD β€” Gmail really does drive UNREAD as a label) means archive is just "remove INBOX" β€” reversible, cheap, and batchable.

The three-pane layout problem

Desktop Gmail is three panes (nav, list, thread); iPad Gmail is two; phone Gmail is one with a stack navigation. Same data, three arrangements β€” so the arrangement can't be hardwired into component structure.

  • One component tree, CSS-driven arrangement. Render list + detail; let container queries (not JS breakpoints) decide whether the detail pane sits beside the list, covers it, or isn't shown.
  • Route is state, pane is view. The selected thread lives in the URL (/inbox/t_901). Three-pane shows it in the right pane; one-pane navigates to it. Back button, refresh, and "open in new tab" all work for free β€” the same trick as the URL-synced range picker.
  • Scroll position is per-pane state. Because the list never unmounts (it's hidden, not destroyed on mobile), returning from a thread restores scroll automatically. This is the #1 "feels like Gmail" tell, and it falls out of keeping the list mounted.

The thread list β€” virtualization with ugly realities

10,000 threads means windowing: render only what's in (or near) the viewport. Email is nastier than a table because rows vary in height β€” one sender vs four senders, attachment chips, long snippets.

  • Variable heights, measured not trusted. Estimate row height, render, measure the real height, correct the scroll offset. Accept transient scrollbar jitter; it beats blocking on layout.
  • Stable keys. Key rows by thread id, never index β€” otherwise new-mail-at-top (which reorders the whole list) makes React re-render every row and your scroll jumps.
  • Overscan ~5 rows. The user scrolls fast through skimmable content; a handful of pre-rendered rows hides most of the pop-in.
  • Restore scroll on back-navigation by storing the list's scroll offset (or first visible id + offset-within-row) in the list state, keyed by the list query. Coming back from thread 3,041 must land exactly where you left.
// The optimistic read: local truth first, server eventually
function markRead(threadId) {
  const prev = store.getState();
  store.setState(s => ({
    threads: { …s.threads, [threadId]: { …s.threads[threadId], unreadCount: 0 } },
  }));
  api.threads.modify(threadId, { removeLabels: ['UNREAD'] })
    .catch(() => {
      // Roll back + surface a sync banner, not a modal
      store.setState(() => prev);
      banner.warn('Could not sync β€” will retry');
      outbox.enqueue({ op: 'modify', threadId, patch: { removeLabels: ['UNREAD'] } });
    });
}

Sync β€” optimistic truth and the delta problem

Marking 50 threads read over flaky Wi-Fi cannot mean 50 spinners. The client is optimistic by design, which creates the actual hard problem: reconciling local optimistic edits with server changes that arrive underneath them.

  • Every mutation goes through the outbox. One queue of pending ops (modify, send, archive), each with an idempotency key. Applied locally immediately, drained to the server in background. Same pattern as the kanban's offline moves.
  • Delta sync, not re-fetch. Keep the server's sync token (historyId in Gmail's API). On reconnect: "give me everything since hist_8712" β€” a tiny diff of changed threads β€” and merge it under your optimistic edits. Never "re-fetch inbox" as a strategy; it nukes scroll, read state, and battery.
  • Conflict policy: server wins on metadata, client wins on drafts. If the server says a thread was read on another device but you just marked it unread here, last-writer-wins by timestamp is fine. Unsent draft content is never server-wins β€” drafts are the one thing users won't forgive losing.
  • Unread counts are derived, cached, and eventually correct. Compute from threads (or maintain incrementally per op). Never hand-maintain a counter in five places β€” that's how counts drift from reality.
  • Push for arrival, sync for state. New mail can arrive via push notification ("thread t_901 changed" β€” not the content, just the hint) triggering a targeted delta. Push the hint, sync the truth.

Compose β€” the surface that survives everything

Gmail's compose is not a page; it's a floating window over the app. That's a product decision with frontend consequences:

  • Compose state lives above routing. Draft state (to, subject, body, attachments) is held in the app shell, not the route, so users can check the inbox mid-email and not lose a word. Minimize = keep state, hide surface.
  • Autosave aggressively, send idempotently. Draft autosaves every few seconds to local storage and drafts folder. Sending enqueues one send op with a clientOpId β€” a retried send can never double-send, same contract as nested comments' optimistic post.
  • Attachments upload out-of-band. Attachments upload immediately with progress (resumable, chunked β€” the file uploader design), so "send" is instant even on a 20MB deck; the send op references already-uploaded blob ids.
  • Undo send is not server-side recall. The mail hasn't left. "Undo" is a client-side delay: the send op sits in the outbox for 5–10 seconds, cancellable. Click undo β†’ cancel the op, reopen compose. It's the command pattern with a delayed execute β€” and the interview point is realizing the "undo" window is a queue feature, not an email feature.
// Undo send = cancellable outbox entry with delayed drain
outbox.enqueue({
  id: clientOpId(),
  type: 'send',
  payload: draftToMessage(draft),
  executeAfter: Date.now() + 5000,        // the "undo window"
});
// drain loop skips ops until executeAfter; cancelSend(id) just deletes the entry

Search and bulk actions

  • Filter loaded, search server-side. Scrolling/filtering the loaded subset ("unread only", "from Priya") is a client-side selector over the list query. Full-text search with operators (from:, has:attachment) is a server query β€” render results as their own list with its own scroll state, and say so in the interview.
  • Debounce server search ~300ms, cancel in-flight requests with AbortController (the fetch-race pattern), and show stale-results-while-loading rather than a blank flash.
  • Bulk select = the list query's ids, not the DOM. "Select all" operates on the query result set (10k threads), not rendered rows β€” with a Gmail-style confirmation: "Select all 10,247 conversations in Inbox?"
  • Batch mutations. One outbox op per action over N ids (modify: {ids: […], removeLabels: ['UNREAD']}), not N ops β€” the server API is batch-shaped and your queue should mirror it.

Offline β€” the outbox is the product

True offline-first falls out of everything above if you commit to two rules:

  • The outbox is durable. Pending ops persist (IndexedDB). Kill the tab mid-send, plane mode mid-archive β€” on next launch, the queue replays, the same op-log pattern as the spreadsheet's offline mode. Every list row can show a sync state (pending/synced/failed) derived from the outbox.
  • Cache the working set, not the mailbox. A service worker caches the app shell plus recently viewed threads (LRU-bounded). Reading recent mail offline is the promise; "all 50k threads offline" is a fantasy that bloats storage for nobody.

Accessibility β€” email is a keyboard product

Gmail's power users live on j/k/e/#/c//. That's not garnish; it's a design constraint:

  • Thread list is a listbox/grid with roving focus, not a div with onClick. Arrow keys move, Enter opens, x selects.
  • Every action has a shortcut AND a visible affordance β€” shortcuts without UI lie to discoverability; UI without shortcuts fails the product.
  • Announce what optimism hides. aria-live for "Conversation archived", "Message sent β€” Undo", new-mail arrivals. Screen reader users can't see a row silently change; the live region is the feedback channel.
  • Focus management on pane switches. Opening a thread moves focus to the thread pane; Escape returns focus to the list at the same thread. Losing focus to the void is the most common a11y failure in split-pane apps.

Interview gotchas β€” the quick-fire round

  • Deriving unreadCount on the thread beats counting unread messages per render β€” and must survive "new reply while open" (does the thread you're reading increment the count you're looking at?).
  • A reply to a 3-month-old thread jumps it to the top of the inbox β€” your optimistic move must replicate exactly that, or the server sync will visibly "undo" your UI.
  • Sending while offline must still feel instant β€” outbox + pending state, never a disabled send button on a network check.
  • Two devices mark the same thread read β€” decide the conflict policy before the interviewer does (metadata: server-wins-by-timestamp; drafts: never lose).
  • Scroll restoration is a data question (store first-visible thread id + offset), not a scrollTop afterthought.
  • Select-all-then-archive 10k threads is one batch op with progress + undo, not 10k requests.
  • Undo send window is configurable because it holds mail hostage client-side β€” say that out loud; it's the senior tell.
  • Draft loss is the one unrecoverable failure β€” autosave to local storage on every keystroke debounce, sync to server drafts when online.

Putting it together

A production email client design:

  • Data model: thread as aggregate root; messages and labels normalized; lists store id-order + cursor + sync token, never copies of data
  • Rendering: virtualized variable-height list with stable keys, per-pane scroll restoration, URL-driven selection, CSS-driven one/two/three-pane responsive strategy
  • Sync: optimistic mutations through a durable idempotent outbox; delta sync off a server history token; server-wins metadata, client-wins drafts
  • Compose: route-independent window, out-of-band attachment uploads, undo send as a cancellable delayed op in the outbox
  • Search: client-side selectors over loaded lists; server-side operator search with cancellation
  • Offline: IndexedDB-persisted op-log, LRU thread cache, per-row sync state
  • A11y: listbox semantics, full keyboard model, live-region announcements for every optimistic change

The list-and-pane UI is the easy 20%. The sync engine β€” optimistic local truth, a durable op queue, and reconciliation against a moving server β€” is the product, and the answer.

The Series: Frontend System Design Interview Questions

Donate