Recommended preparation
Try first
Frame your answer before reading the reference
Senior20 min first pass
Candidate prompt
Design an offline-first web email client with normalized mailbox state, incremental sync, durable drafts, idempotent sending, safe message rendering, and accessible navigation.
Constraints
- Keep client, server, and rendering ownership explicit.
- Cover loading, failure, recovery, and accessible interaction states.
Helpful before you start
- Client-side state management
- Accessible UI fundamentals
Make these three decisions explicit
- State and ownership boundaries
- API and event contracts
- Performance and failure tradeoffs
Requirements and offline scope
Gmail offline architecture, not interview provenance
This case uses public Gmail-like product behavior and published Gmail API documentation as design inspiration. It is not a confirmed or leaked Google interview question. Every endpoint and component described here is an illustrative frontend contract, not a claim about Google's private implementation or internal architecture.
Design a browser-first Gmail frontend system design for a signed-in user who scans an inbox, opens threads, composes durable drafts, manages labels, searches, and sends while connectivity changes. Incremental mailbox sync must keep server-authoritative facts separate from local intent, preserve intentionally cached content, and state whether an action is queued or confirmed.
Read and organize
- Browse Inbox, Sent, Drafts, and labels through a virtualized email inbox with stable selection.
- Open a thread and lazy-load bodies or attachments without losing reading position.
- Archive, mark read, star, and change labels through recoverable commands.
- Search the full mailbox online and label offline results as cached-only.
Compose and send
- Persist recipients, subject, body, reply context, and attachment references across reloads.
- Autosave locally offline, then synchronize a versioned draft after reconnect.
- Queue Send durably without fabricating a Sent message.
- Retry an unknown outcome without creating a duplicate message.
Offline states and correctness
| State | Expected experience | Invariant |
|---|---|---|
| Initial launch without cache | Show an accessible shell, loading labels, retry, or sign-in recovery. | A failed load never looks like an empty mailbox. |
| Cached hydration and syncing | Render cached rows with freshness while requesting a snapshot or delta. | Cached revisions are not silently promoted to fresh. |
| Online and caught up | Merge deltas without moving selection, scroll anchor, draft, or focus. | Each logical entity has one normalized identity. |
| Offline or stale | Keep cached reading and drafting usable; queue supported actions and explain unavailable resources. | Pending local intent is not server truth. |
| Expired sync cursor | Fetch a full snapshot and reconcile it with retained drafts and outbox commands. | Cache replacement cannot erase pending local intent. |
| Quota or persistence denied | Protect active drafts first, reduce offline coverage, and offer cache cleanup. | Server-backed bodies and attachments are evicted before drafts or commands. |
| Authentication failure or account switch | Pause sync, hide the old mailbox, and require recovery. | No cache, search result, draft, or command crosses account namespaces. |
Measurable targets
| Concern | Initial target | Evidence |
|---|---|---|
| Warm mailbox | Useful cached rows within 300 milliseconds on a mid-tier reference device. | Measure 10,000 stored thread summaries. |
| Draft durability | Acknowledged edits survive reload, offline transition, and simulated tab crash. | Compare draft revision and all compose fields after each boundary. |
| Convergence | The mailbox settles after a successful command flush and following delta. | Inject duplicates, lost responses, and expired cursors. |
| Responsive accessibility | Keyboard operation, readable status, and no document overflow at 320 and 390 pixels. | Test keyboard, screen reader, 200 percent zoom, long subjects, tables, and code. |
Email client frontend system design scope
Deliberately out of scope
- SMTP, mail retrieval protocols, delivery queues, bounces, and cross-provider deliverability.
- Spam classification, ranking, notifications, and backend full-text indexing.
- Server storage topology, attachment object storage, and disaster recovery.
- Contacts, calendars, advertising, and end-to-end encryption protocols.
- Reproducing Gmail's private internals.
The frontend owns normalized state, IndexedDB caching, offline draft sync, the email outbox pattern, safe rendering, attachment UX, accessibility, and reconciliation. Services own authentication, authoritative revisions, mailbox history, idempotent execution, server search, delivery, authorization, and malware outcomes. Browser storage supports continuity but is neither an unlimited archive nor a confidentiality boundary.
Architecture and mailbox synchronization
Use one reconciliation pipeline for online responses, mailbox deltas, cache hydration, and multi-tab wake-ups. Components express read or write intent through a mailbox facade; they do not call transport, edit revisions, or insert message HTML. Version-aware reducers keep network arrival order from creating a second mailbox.
Mailbox route and responsive panes
-> normalized selectors
-> mailbox facade
-> snapshot and delta coordinator
-> durable draft repository
-> idempotent outbox coordinator
-> safe body and attachment cache
-> account-scoped IndexedDB
-> versioned mailbox service
Push, SSE, or BroadcastChannel
-> invalidation hint only
-> fetch authoritative delta
-> reconcile through one reducer Four-part canonical model
Separate the normalized server-authoritative mailbox projection; durable local intent containing drafts and outbox commands; ephemeral selection, focus, filters, and request state; and a safe-render boundary for untrusted bodies. A full sync may replace the projection but cannot erase local intent. Neither server content nor cached markup bypasses the render boundary.
| Module | Owns | Boundary |
|---|---|---|
| MailboxStore | Normalized entities, query IDs, revisions, cursor, overlays, and freshness. | Bodies are not copied into inbox rows. |
| IndexedDbRepository | Account-scoped cache generations, drafts, commands, and migrations. | It stores revisions but does not decide authority. |
| SyncCoordinator | Hydration, snapshot or delta, cursor advancement, cancellation, and recovery. | It never infers success from connectivity. |
| OutboxCoordinator | Durable scheduling, idempotency, retry, and command reconciliation. | It never fabricates a Sent message. |
| BodyRenderBoundary | Sanitization, URL policy, remote-image consent, and resource teardown. | Sender markup never reaches the DOM directly. |
| MailboxViewport and TabCoordinator | Virtual range, stable focus, advisory wake-ups, and a preferred flusher lease. | BroadcastChannel is not durable authority. |
Boot and synchronization flow
- Hydrate one accountOpen the authenticated account namespace and read cached entities, query IDs, cursor, drafts, and outbox. Render cached summaries with freshness while keeping account switches isolated.
- Establish authorityRequest changes after a valid opaque sync cursor, otherwise fetch a snapshot. A cursor locates history; it is not an entity or draft revision.
- Commit reconciliationValidate account and schema, apply only newer revisions, update query membership, and persist entities with the replacement cursor in one transaction. Stable IDs preserve selection and scroll anchor.
- Flush intentSubmit persisted commands after authentication and capabilities are known. Command responses and matching events can arrive in either order and converge through identity and revision.
Offline email client architecture invariants
Threads, immutable messages, labels, and draft references live in maps keyed by stable server identity. Ordered inbox results store IDs plus query metadata, not copied entities. Message bodies, attachment bytes, and local search indexes remain disposable resources with independent version and eviction metadata.
Applying a mailbox checkpoint
- Require snapshot pages to share a declared checkpoint; restart instead of combining pages from incompatible generations.
- Validate event kind, account, schema version, entity revision, and payload limits before changing visible state.
- Ignore replayed identities and older entity revisions while still accepting a newer thread summary after a message update.
- Recompute label and search-query membership from event semantics without scanning or cloning every cached entity.
- Advance syncCursor only in the transaction that persists its corresponding events, so a crash replays safe work.
- Keep pending command overlays separate and remove one only after an authoritative response or matching event reconciles it.
Invalidation is not mailbox truth
Push or SSE marks the mailbox stale and schedules a delta fetch; it does not authoritatively add a message or update a count. BroadcastChannel only wakes another same-origin tab to reread durable state or fetch changes. Browser online events may accelerate retry but prove neither reachability nor command outcome.
Expired cursor recovery
When the changes endpoint rejects an expired cursor, fetch a snapshot into a staging cache generation, validate it, and atomically replace only server-derived entities and query indexes. Preserve local drafts, outbox commands, idempotency keys, and attachment staging in separate stores. Reapply pending presentation overlays, reconcile command identities already visible in the snapshot, and resume from its new cursor.
Worked example: the send succeeds and the response disappears
The user sends draft d-19 offline. One IndexedDB transaction saves the draft and cmd-73, whose clientCommandId and idempotency key remain stable. After reconnect, the service accepts cmd-73 and creates m-205, but the response is lost. The client retains Unknown or Retrying state and creates no Sent row. A later mailbox delta carries m-205 with clientCommandId cmd-73, allowing one transaction to upsert the message, update its thread, acknowledge the command, and remove the draft only after authoritative confirmation.
offline: persist d-19 + cmd-73 -> show Queued
reconnect: POST send(cmd-73) -> server creates m-205
network: response disappears
sync: message.upserted(m-205, clientCommandId = cmd-73)
reconcile: add m-205 once -> acknowledge cmd-73 -> retire d-19| Moment | Durable result | Invariant |
|---|---|---|
| Send pressed offline | Commit d-19 and cmd-73 before showing Queued. | A crash cannot erase acknowledged local intent. |
| Response disappears | Keep cmd-73 pending and reuse its original identity. | Silence is not evidence of failure or success. |
| Retry reaches the service | The idempotency key maps to the existing result. | One command creates at most one message. |
| Delta echoes cmd-73 | Upsert m-205, update the thread, acknowledge the command, and retire d-19 together. | Local intent converges with one server fact. |
| Cursor expires first | Replace mailbox cache, preserve d-19 and cmd-73, then reconcile the fresh snapshot. | Full sync cannot delete unresolved intent. |
Route changes abort obsolete reads but not persisted draft or send intent. Narrow selectors keep body fetches from rerendering the inbox. Wide tables and code scroll inside their containers; mobile list, thread, and compose panes reuse the same store and restore focus by stable identity.
Data model, drafts, and queued intent
An offline email client needs more than a cached array of inbox rows. The same message can appear in an inbox, search result, starred view, and thread, while a label can belong to many messages. Normalize shared entities by immutable identity, then separate server-confirmed mailbox data from local user intent. A full synchronization may replace the mailbox projection, but it must never erase an unconfirmed draft, attachment upload, or send command. Selection and scroll position are view state; downloaded bodies, previews, and search indexes are disposable resources.
Email client state management entities
| Record | Stable identity and relationships | Mutation rule |
|---|---|---|
| Thread | One thread ID references ordered message IDs and summarizes subject, snippet, participants, labels, unread count, and latest activity. | A newer server revision replaces summary fields. The client derives its row from normalized messages instead of copying complete messages into every list. |
| Message | One immutable message ID belongs to a thread and references any number of label IDs. One label can therefore appear on any number of messages. | Treat delivered content as immutable. A newer representation is an upsert by the same identity and revision, not an in-place composer edit. |
| Label | One label ID describes a system or user label. Message-to-label references model the many-to-many relationship. | Label updates can rename or recolor a user label without rewriting every stored row. Membership changes arrive through message or thread deltas. |
| DraftDocument | A stable draft ID owns the evolving composer document and may reference a thread. Its content revision is local; its server revision is optional. | Saving replaces the draft content revision while preserving the container identity. A failed or superseded save never turns a draft into an immutable delivered message. |
| DraftAttachment | A client-generated attachment ID belongs to one draft and survives upload retries. A ready record maps that identity to one finalized server asset ID. | Track selected, uploading, processing, ready, failed, blocked, and canceled states explicitly. Send is eligible only when every referenced attachment is ready; retry reuses the upload session or idempotency key. |
| OutboxCommand | A stable command ID and idempotency key describe one save, send, or label intent based on a known revision. | Retry the same command identity until an authoritative delta or command outcome reconciles it. Do not mint a new key after an ambiguous response. |
type MailboxAddress = {
name?: string;
address: string;
};
type Thread = {
id: string;
messageIds: readonly string[];
labelIds: readonly string[];
subject: string;
snippet: string;
participants: readonly MailboxAddress[];
lastMessageAt: string;
unreadMessageCount: number;
revision: string;
};
type Message = {
readonly id: string;
readonly threadId: string;
readonly labelIds: readonly string[];
readonly from: MailboxAddress;
readonly to: readonly MailboxAddress[];
readonly sentAt: string;
readonly subject: string;
readonly snippet: string;
readonly bodyResourceId: string;
readonly attachmentIds: readonly string[];
readonly revision: string;
readonly clientCommandId?: string;
};
type Label = {
id: string;
name: string;
kind: 'system' | 'user';
color?: string;
revision: string;
};
type MailboxSnapshot = {
mailboxId: string;
syncCursor: string;
threads: readonly Thread[];
messages: readonly Message[];
labels: readonly Label[];
capturedAt: string;
};
type DraftAttachmentBase = {
attachmentId: string;
draftId: string;
fileName: string;
contentType: string;
sizeBytes: number;
};
type DraftAttachment =
| (DraftAttachmentBase & {
status: 'selected';
uploadedBytes: 0;
})
| (DraftAttachmentBase & {
status: 'uploading' | 'processing';
uploadSessionId: string;
uploadedBytes: number;
})
| (DraftAttachmentBase & {
status: 'ready';
uploadSessionId: string;
uploadedBytes: number;
assetId: string;
assetRevision: string;
})
| (DraftAttachmentBase & {
status: 'failed';
uploadSessionId?: string;
uploadedBytes: number;
failureCode: string;
retryable: boolean;
})
| (DraftAttachmentBase & {
status: 'blocked';
uploadSessionId: string;
uploadedBytes: number;
assetId: string;
failureCode: string;
})
| (DraftAttachmentBase & {
status: 'canceled';
uploadedBytes: number;
});
type DraftContent = {
to: readonly MailboxAddress[];
cc: readonly MailboxAddress[];
bcc: readonly MailboxAddress[];
subject: string;
bodyHtml: string;
attachmentIds: readonly string[];
};
type DraftDocument = {
draftId: string;
mailboxId: string;
threadId?: string;
content: DraftContent;
contentRevision: number;
baseRemoteRevision: string | null;
remoteRevision?: string;
updatedAt: string;
};
type RemoteDraftRevision = {
draftId: string;
mailboxId: string;
threadId?: string;
content: DraftContent;
revision: string;
acknowledgedContentRevision?: number;
updatedAt: string;
};Frontend email outbox pattern and durable intent
type CommandBase = {
commandId: string;
idempotencyKey: string;
baseRevision: string | null;
createdAt: string;
};
type OutboxCommand =
| (CommandBase & {
kind: 'draft.save';
draftId: string;
document: DraftDocument;
})
| (CommandBase & {
kind: 'draft.send';
draftId: string;
expectedContentRevision: number;
attachmentAssetIds: readonly string[];
})
| (CommandBase & {
kind: 'thread.labels.set';
threadId: string;
addLabelIds: readonly string[];
removeLabelIds: readonly string[];
});
type PendingCommand = {
command: OutboxCommand;
status: 'queued' | 'submitting' | 'awaiting-delta' | 'conflict' | 'failed';
attemptCount: number;
retryAfter?: string;
lastErrorCode?: string;
};
type MailboxProjection = {
threadsById: Record<string, Thread>;
messagesById: Record<string, Message>;
labelsById: Record<string, Label>;
remoteDraftsById: Record<string, RemoteDraftRevision>;
syncCursor: string | null;
phase: 'empty' | 'cached' | 'syncing' | 'fresh' | 'stale' | 'recovering';
};
type LocalIntentState = {
draftsById: Record<string, DraftDocument>;
attachmentsById: Record<string, DraftAttachment>;
commandsById: Record<string, PendingCommand>;
};
type MailboxClientState = {
projection: MailboxProjection;
localIntent: LocalIntentState;
view: {
selectedThreadId?: string;
activeLabelId?: string;
query: string;
};
resources: {
bodyCacheKeys: readonly string[];
attachmentCacheKeys: readonly string[];
localSearchVersion?: string;
};
};| Layer | Examples | Recovery behavior |
|---|---|---|
| Server document cache | Normalized threads, immutable messages, labels, entity revisions, and the opaque sync cursor. | Merge only from snapshot and delta contracts. An expired cursor permits a new snapshot because this layer projects server truth. |
| Durable local intent | Draft documents, attachment lifecycle records, finalized asset identities, outbox commands, idempotency keys, attempt counts, and ambiguous send state. | Persist it in one transaction before optimistic UI. Preserve it across reload, offline periods, cursor recovery, and cache eviction. |
| Cache policy metadata | Last access time, byte estimates, account partition, schema version, and eviction class. | Use it to remove old bodies and previews without removing drafts or commands. |
| Ephemeral view state | Current route, selected thread, expanded composer, keyboard focus target, filters, and scroll anchor. | Recreate from the URL and defaults. Stable IDs preserve selection and virtual-list anchoring across deltas. |
| Disposable resources | Sanitized body fragments, remote-image decisions, attachment previews, decoded blobs, and the local cached-mail search index. | Rebuild on demand when source revision, sanitizer version, account, or authorization changes. |
Revision and deletion semantics
Applying a mailbox change safely
- Compare entity revisionIgnore an older replay, accept a newer upsert, and make equal revisions idempotent. The cursor cannot replace per-entity revision checks.
- Update normalized relationshipsMerge the message once, then update thread and label indexes from authoritative events. Search and inbox rows read the same entities.
- Reconcile matching intentWhen an event echoes clientCommandId, confirm that outbox command. A remote draft revision advances the acknowledged base, but newer local content remains intact; incompatible concurrent changes retain both revisions as an explicit conflict. Remove a sent draft only after the authoritative outcome, even if the send response disappeared.
- Handle removal explicitlyA message.removed event changes the server projection, not unrelated drafts or commands awaiting conflict resolution.
- Commit cursor with the mergeCommit entity changes and cursor in one transaction. On abort, replay from the previous cursor instead of exposing partial state.
Account partition is part of every key
Prefix or structurally partition IndexedDB records by authenticated mailbox ID. On account switch, close active readers, clear view and resource state, and open the new partition before rendering cached rows. Never show one account's subject lines while another account is loading, and never coalesce commands across accounts even if their draft IDs happen to match.
Interface contracts for sync and sending
The browser needs a small contract surface separating authoritative reads from durable commands. Snapshot and changes endpoints deliver mailbox truth; draft save, attachment, send, and label endpoints accept version-aware intent. The browser treats snapshot, delta, draft, attachment upload and download, send, and label service endpoints as one versioned contract. Mail delivery, malware scanning, and provider storage remain behind the gateway, outside browser ownership. Responses expose identities and revisions for deterministic reconciliation; transport errors never decide final authoritative mailbox truth. Cancellation, retry timing, and persistence remain adapter details.
Snapshot, delta, and detail reads
| Request | Response contract | Frontend responsibility |
|---|---|---|
| GET /mailboxes/:id/snapshot?label=INBOX&pageToken= | MailboxSnapshot with optional page token, opaque syncCursor, threads, messages, and labels. | Seed normalized storage and persist the cursor with entities. Page tokens paginate views; they never replace the change cursor. |
| GET /mailboxes/:id/changes?after= | Bounded MailboxDelta with ordered events, command outcomes, and next syncCursor. | Fetch after reconnect or invalidation. Deduplicate events and advance the cursor only after the local merge commits. |
| GET /threads/:id | Versioned Thread and Message summaries, followed by body requests when needed. | Fill missing detail without treating it as a mailbox snapshot. Abort when route or account changes. |
| GET /messages/:id/body | Bounded body with content type, source revision, remote-resource metadata, and attachment references. | Sanitize HTML and cache by message revision and sanitizer version. Never execute sender content. |
type CommandOutcome =
| {
commandId: string;
status: 'applied';
clientCommandId: string;
serverRevision: string;
}
| {
commandId: string;
status: 'conflict';
currentRevision: string;
reason: 'revision-mismatch' | 'draft-replaced' | 'already-sent';
}
| {
commandId: string;
status: 'rejected';
reason: 'invalid' | 'forbidden' | 'attachment-unavailable';
};
type MailboxEvent =
| { type: 'message.upserted'; eventId: string; message: Message; clientCommandId?: string }
| { type: 'message.removed'; eventId: string; messageId: string; revision: string }
| { type: 'thread.updated'; eventId: string; thread: Thread }
| { type: 'label.updated'; eventId: string; label: Label }
| { type: 'draft.replaced'; eventId: string; draft: RemoteDraftRevision; clientCommandId?: string };
type MailboxDelta = {
mailboxId: string;
afterCursor: string;
syncCursor: string;
events: readonly MailboxEvent[];
commandOutcomes: readonly CommandOutcome[];
hasMore: boolean;
};
type ThreadDetail = {
thread: Thread;
messages: readonly Message[];
};
type MessageBodyResource = {
messageId: string;
sourceRevision: string;
contentType: 'text/plain' | 'text/html';
content: string;
hasRemoteImages: boolean;
attachmentIds: readonly string[];
};Gateway and reducer boundaries
type SaveDraftResult = {
draft: RemoteDraftRevision;
clientCommandId: string;
};
type SendAccepted = {
status: 'accepted';
commandId: string;
clientCommandId: string;
reconciliation: 'await-mailbox-delta';
};
type LabelCommandResult = {
status: 'applied' | 'accepted';
commandId: string;
clientCommandId: string;
threadRevision?: string;
};
type AttachmentUploadSession = {
uploadSessionId: string;
attachmentId: string;
committedBytes: number;
expiresAt: string;
};
type AttachmentAsset = {
assetId: string;
assetRevision: string;
readiness: 'processing' | 'ready' | 'blocked';
};
type AttachmentDownload = {
assetId: string;
url: string;
expiresAt: string;
contentDisposition: 'inline' | 'attachment';
};
interface MailboxGateway {
getSnapshot(input: {
mailboxId: string;
labelId?: string;
pageToken?: string;
signal: AbortSignal;
}): Promise<{ snapshot: MailboxSnapshot; nextPageToken?: string }>;
getChanges(input: {
mailboxId: string;
after: string;
signal: AbortSignal;
}): Promise<MailboxDelta>;
getThread(threadId: string, signal: AbortSignal): Promise<ThreadDetail>;
getMessageBody(messageId: string, signal: AbortSignal): Promise<MessageBodyResource>;
createAttachmentUpload(input: {
draftId: string;
attachmentId: string;
fileName: string;
contentType: string;
sizeBytes: number;
idempotencyKey: string;
signal: AbortSignal;
}): Promise<AttachmentUploadSession>;
uploadAttachmentBytes(input: {
uploadSessionId: string;
offset: number;
bytes: Blob;
signal: AbortSignal;
onProgress: (uploadedBytes: number) => void;
}): Promise<{ committedBytes: number }>;
finalizeAttachmentUpload(input: {
uploadSessionId: string;
idempotencyKey: string;
signal: AbortSignal;
}): Promise<AttachmentAsset>;
getAttachmentAsset(assetId: string, signal: AbortSignal): Promise<AttachmentAsset>;
cancelAttachmentUpload(input: {
uploadSessionId: string;
idempotencyKey: string;
signal: AbortSignal;
}): Promise<void>;
createAttachmentDownload(assetId: string, signal: AbortSignal): Promise<AttachmentDownload>;
saveDraft(command: Extract<OutboxCommand, { kind: 'draft.save' }>): Promise<SaveDraftResult>;
sendDraft(command: Extract<OutboxCommand, { kind: 'draft.send' }>): Promise<SendAccepted>;
setThreadLabels(
command: Extract<OutboxCommand, { kind: 'thread.labels.set' }>,
): Promise<LabelCommandResult>;
}
type DraftReconciliation =
| { status: 'acknowledged'; draftId: string; baseRemoteRevision: string }
| { status: 'base-advanced'; draftId: string; baseRemoteRevision: string; preservedContentRevision: number }
| { status: 'conflict'; draftId: string; remote: RemoteDraftRevision; localContentRevision: number };
declare function upsertMessage(projection: MailboxProjection, message: Message): void;
declare function removeMessage(projection: MailboxProjection, messageId: string, revision: string): void;
declare function upsertThread(projection: MailboxProjection, thread: Thread): void;
declare function upsertLabel(projection: MailboxProjection, label: Label): void;
declare function reconcileRemoteDraft(
projection: MailboxProjection,
localIntent: LocalIntentState,
draft: RemoteDraftRevision,
clientCommandId?: string,
): DraftReconciliation;
function assertNever(value: never): never {
throw new Error(`Unsupported mailbox event: ${String(value)}`);
}
function applyMailboxEvent(
projection: MailboxProjection,
localIntent: LocalIntentState,
event: MailboxEvent,
): void {
switch (event.type) {
case 'message.upserted':
upsertMessage(projection, event.message);
return;
case 'message.removed':
removeMessage(projection, event.messageId, event.revision);
return;
case 'thread.updated':
upsertThread(projection, event.thread);
return;
case 'label.updated':
upsertLabel(projection, event.label);
return;
case 'draft.replaced':
reconcileRemoteDraft(projection, localIntent, event.draft, event.clientCommandId);
return;
default:
assertNever(event);
}
}Push or server-sent events announce only that a mailbox may have changed. They are not authoritative payloads and do not advance syncCursor. The coordinator coalesces invalidations, calls changes from its committed cursor, and applies the event union exhaustively. Missed or repeated notifications remain safe because the delta endpoint is authoritative. An expired cursor triggers a fresh snapshot while separate durable DraftDocument, DraftAttachment, and OutboxCommand records survive. For draft.replaced, the reducer records server truth in MailboxProjection and reconciles LocalIntentState: a matching acknowledgement advances the base, newer local edits remain intact, and incompatible concurrent revisions become an explicit conflict. It never assigns remote draft content over a newer local document.
Versioned command endpoints
| Request | Required concurrency fields | Resolution rule |
|---|---|---|
| PUT /drafts/:id | If-Match carries baseRevision; Idempotency-Key carries the durable key; the body carries commandId, clientCommandId, and content revision. | Success returns the stable draft ID and remote revision. Conflict returns the current revision without discarding the local document. |
| POST /drafts/:id/send | Carries commandId, clientCommandId, idempotency key, base revision, and expected content revision. | Acceptance must not invent a Sent message. Await an authoritative message or outcome echoing clientCommandId. |
| POST /threads/:id/label-commands | Carries exact add and remove label sets, command identity, and thread base revision. | Project optimistically, then confirm by revision or delta. On conflict, rebase intent against current membership. |
| POST /drafts/:id/attachment-uploads, PUT /attachment-uploads/:id, POST /attachment-uploads/:id/finalize | Carries stable attachment identity, upload-session identity, byte offset, size, and idempotency key. | Resume from the committed offset after interruption. Only a finalized asset whose readiness is ready may enter a send command; processing, failed, blocked, or canceled attachments keep Send unavailable. |
| POST /attachments/:id/download | Carries authorized asset identity; the gateway returns a short-lived download capability and content disposition. | Request it only after explicit preview, open, or save intent. The browser never reaches provider storage directly and never auto-downloads an asset before its scan is complete. |
PUT /drafts/draft_91
If-Match: draft-r12
Idempotency-Key: idem-save-91-13
Content-Type: application/json
{
"commandId": "cmd-save-13",
"clientCommandId": "cmd-save-13",
"baseRevision": "draft-r12",
"contentRevision": 13,
"content": {
"to": [{ "address": "reader@example.com" }],
"cc": [],
"bcc": [],
"subject": "Offline notes",
"bodyHtml": "<p>Draft body</p>",
"attachmentIds": []
}
}
POST /drafts/draft_91/send
If-Match: draft-r13
Idempotency-Key: idem-send-91
Content-Type: application/json
{
"commandId": "cmd-send-91",
"clientCommandId": "cmd-send-91",
"baseRevision": "draft-r13",
"expectedContentRevision": 13,
"attachmentAssetIds": []
}
202 Accepted
{
"status": "accepted",
"commandId": "cmd-send-91",
"clientCommandId": "cmd-send-91",
"reconciliation": "await-mailbox-delta"
}Lost-response send reconciliation
- Persist before transportWrite draft.send with its command ID, idempotency key, base revision, and expected draft content revision in the same durable transaction that marks the composer as queued.
- Retry the identical commandIf the server accepted the send but its response disappeared, reconnecting code submits the same idempotency key. It does not create a second command or a speculative delivered Message.
- Catch up from mailbox truthFetch changes after the last committed cursor. A message.upserted event carrying the matching clientCommandId proves which server message resulted from the command.
- Converge onceUpsert the authoritative message by its server ID, clear exactly one outbox record, remove the confirmed draft, and advance the cursor in one transaction. A replay sees the same identities and creates no duplicate Sent row.
Search has two explicit scopes
Full-mailbox search belongs to the server because the browser may hold only partial mail and evicted bodies. Results return stable IDs, scope, query token, and page cursor; normal detail contracts hydrate them. Local search runs over cached headers, snippets, and indexed bodies, but the UI labels it as cached mail only. Offline mode keeps those limited results available and offers a retry when connectivity returns.
HTTP status is not domain reconciliation
A successful command response can be lost, and a timeout can hide a successful send. Conversely, an accepted response can precede the mailbox delta that makes the result visible. The interface therefore exposes command identity, idempotency, revisions, and clientCommandId; the normalized delta merge, not transport optimism, decides the final mailbox state.
Optimizations for performance, security, and accessibility
Optimize measured bottlenecks without weakening stable identities, durable intent, security boundaries, or accessible focus.
Virtualized inbox and bounded resources
| Area | Technique | Guardrail |
|---|---|---|
| Thread list | Virtualize only after DOM, layout, or memory measurements justify it | Key rows by thread ID; keep selection outside mounted rows; overscan or pin the focused row |
| Changing rows | Use entity-level subscriptions and batch ordinary sync deltas | Flush visible removals and command acknowledgements promptly; anchor scroll by thread identity, not index |
| Message bodies | Fetch the selected body lazily and cache sanitized output by message revision and policy version | Cancel obsolete requests; bound cached bodies by bytes and evict closed items first |
| Attachments | Load metadata first and bytes after an explicit preview, open, or save action | Expose progress, cancellation, scan state, retry, and permission failure; bound preview memory |
| Search | Index cached text incrementally, using a worker only after profiling | Label offline results as locally available scope rather than full-mailbox search |
IndexedDB quota and multi-tab coordination
| Data | Policy |
|---|---|
| Drafts and outbox commands | Durable user intent: write drafts, attachment lifecycle records, and commands in one transaction and never evict them silently; surface storage failure with copy or export recovery |
| Snapshot and cursor | Commit one internally consistent generation; an interrupted replacement cannot become the active base |
| Bodies, previews, and search index | Disposable byte-bounded caches: evict attachments, indexes, old bodies, then obsolete snapshots before intent |
| Persistent storage request | Use as a progressive enhancement; denial leaves a safe reduced-cache mode |
Browser storage improves availability, not confidentiality. Partition storage, memory, workers, and requests by account; clear data on sign-out or account switch. BroadcastChannel may announce committed records and elect a short-lived sync leader, but delivery is advisory. Persist before broadcasting, reload after waking, and let server versions and deltas resolve conflicts. Background Sync is only a progressive enhancement: the browser may not support it or schedule work after the tab closes. Durable draft and outbox records therefore remain queued and flush at the next eligible foreground or reconnect opportunity; the UI never promises background delivery.
HTML security, remote content, and accessibility
| Concern | Required behavior |
|---|---|
| Untrusted HTML | Use a maintained allowlist sanitizer at one rendering boundary; remove scripts, forms, handlers, active embeds, unsafe styles, and unsafe URL schemes |
| Trusted Types | Create one audited policy for sanitized bodies; ordinary components cannot construct trusted HTML. CSP and sandbox isolation remain defense in depth |
| Remote images | Do not contact sender origins on open or prefetch; use an approved privacy proxy or explicit click-to-load control |
| Attachments | Render filenames as text, isolate previews, revoke blob URLs, and distinguish scanning, blocked, clean, failed, and expired states without color alone |
| Inbox semantics | Prefer a named list of native links and buttons; use an ARIA grid only when real cell navigation warrants its complete keyboard and focus model |
| Composer shortcuts | Preserve Tab order and disable single-key mailbox shortcuts inside the composer, search, recipient editor, and every editable surface |
| Focus and announcements | After removal, focus the nearest surviving thread by ID. Announce offline, queued, confirmed, conflict, and terminal outcomes once, not every sync event |
| Narrow and international layouts | Stack panes at 320 and 390 pixels; contain body width; wrap long tokens; test large text, bidirectional addresses, and on-screen keyboards |
Recovery behavior by fault
| Failure | Recovery invariant |
|---|---|
| Offline | Keep cached mail readable and drafts local; no queued action appears synchronized before authoritative confirmation |
| Send succeeds but response is lost | Reuse the idempotency key or reconcile a delta by clientCommandId; one logical send creates one Sent message |
| Sync cursor expires | Commit a fresh snapshot while preserving account-scoped drafts and outbox commands, then reconcile them |
| Quota exhausted | Reduce disposable caches before durable intent; keep the last committed draft and explain degraded offline support |
| Authentication expires or account switches | Pause work and reject stale responses; never replay commands or reveal cache across authorization scopes |
| Draft revision conflicts | Record the remote revision as acknowledgement, newer base, or conflict without overwriting newer local intent; preserve both documents and require an explicit accepted base before saving |
| Malicious HTML | Render sanitized content or plain text; markup cannot execute, submit forms, install handlers, or use unsafe navigation |
| Remote image blocked | Reserve layout and offer deliberate loading when permitted; opening the message causes no sender-origin request |
| Attachment fails | Keep reading available and preserve the stable draft-attachment identity, committed byte offset, and local recovery data; retry the same upload session or idempotency key, and never enable Send or start a download before the finalized asset is ready |
| Push gap or leader-tab crash | The next eligible foreground or periodic delta reconciliation restores state; push, Background Sync, and BroadcastChannel remain replaceable hints rather than delivery guarantees |
Measurement, testing, and rollout
Signals and test coverage
- Measure time to cached and fresh inbox, thread-open latency, long tasks, mounted rows, scroll-anchor corrections, and retained bytes.
- Track draft-save success, conflicts, quota failures, outbox age, confirmation latency, cursor expiry, stale time, and duplicate-send prevention without collecting mail content.
- Test sanitization corpora, remote-origin network blocking, attachment upload progress, cancel, retry, finalization and download states, account isolation, crash recovery, lost responses, expired cursors, and cross-tab races.
- Test browsers without Background Sync and tab-close cases; queued commands must remain durable and flush on the next eligible foreground or reconnect without claiming background delivery.
- Automate keyboard tasks, identity-based focus restoration, live-region deduplication, 200 percent zoom, forced colors, 320 and 390 pixel overflow, long text, and bidirectional fixtures.
Progressive rollout
- Cached read pathShip normalized snapshots, account isolation, safe body rendering, responsive panes, and accessible navigation.
- Durable intentAdd drafts, then idempotent outbox commands behind flags after quota, conflict, crash, and lost-response tests pass.
- Incremental synchronizationAdd deltas, push invalidation, and advisory tab leadership while shadow-comparing reduced state with fresh snapshots.
- Measured tuningTune virtualization, cache budgets, search, and previews from field data; rollback presentation without deleting durable intent.
Design review checkpoint
- Why stable thread identity protects selection, scroll anchoring, and focus during virtualization.
- Why drafts, attachment lifecycle records, and outbox commands survive cache eviction, cursor expiry, and presentation rollback.
- Why lost send responses converge through idempotency keys and clientCommandId without duplicate Sent mail.
- Why BroadcastChannel and push reduce latency while server revisions and deltas remain authoritative.
- Why sanitization, remote-image privacy, attachment isolation, native controls, and composer-safe shortcuts are separate boundaries.
Technical references
Gmail API synchronization guide Full and partial sync, history cursors, and recovery. Gmail API threads guide Public message grouping behavior. Gmail API labels guide Public label and message relationships. Gmail API drafts guide Public draft-container lifecycle. MDN IndexedDB API Transactional structured browser storage. MDN storage quotas and eviction criteria Quota, persistence, and eviction behavior. MDN BroadcastChannel API Same-origin cross-context messaging. MDN Background Synchronization API Optional deferred work with limited browser availability. OWASP cross-site scripting prevention Sanitization, safe sinks, and URL defenses. WAI-ARIA grid pattern Grid keyboard and focus expectations.
Use the Question Library for baseline coverage, then move into a Study Plan before targeted Company Prep.