Design problems
Messaging & collaborationHardSDE-2 35 min

Design an email service (Gmail)

Asked at
AppleGoogleMicrosoftZoho

The interviewer asks

“Design a web-based email service like Gmail. Users can send and receive email with attachments, organise mail into threads and labels, search their mailbox, and get new mail on web and mobile within seconds.”

What a strong answer looks like

A strong answer separates the two halves of email: the SMTP world (inbound MX servers, spam filtering, outbound delivery with retries and sender reputation) and the mailbox world (a per-user store, threads, labels, search and sync). Everything is partitioned by user id, because almost every read is 'my mailbox'. Message metadata goes into a wide-column store keyed by user, bodies and attachments go to blob storage with content-hash dedup, and each user gets their own small search index updated asynchronously. Clients sync through a per-mailbox change log (a history id), so a phone that was offline for a day fetches only what changed. The deep dives are the inbound pipeline (SPF/DKIM/DMARC, spam, idempotent delivery), the storage layout, per-user search, and outbound deliverability.

Concepts to mention

SMTP inbound/outbound split and MX recordsAccept with 250 only after durable writeSPF, DKIM and DMARCPartition everything by user_idMetadata in wide-column store, bodies in blob storageContent-hash dedup of attachmentsPer-user search indexHistory id / change log for incremental syncPush a signal, client pulls changesPer-domain outbound queues with backoffSender IP reputation and outbound rate limitsIdempotent delivery using Message-ID

1 Requirements

Functional

  • Receive email from any internet mail server over SMTP and deliver it to the right mailbox
  • Send email (with attachments up to 25 MB) to internal and external recipients
  • Group messages into conversations (threads)
  • Labels/folders, read/unread, star, archive, delete (to Trash, purged after 30 days)
  • Full-text search over the user's own mail (sender, subject, body, attachment names)
  • Spam and phishing filtering with a Spam folder
  • New-mail notifications on web (push) and mobile (FCM/APNs); incremental sync for offline clients
  • Standard protocol access (IMAP) for third-party clients

Non-functional

  • Scale: 1B users, ~40B inbound and ~5B outbound messages per day
  • Durability: an accepted message is never lost (11 nines on stored mail; at least 3 replicas or erasure coding across zones)
  • Inbox list loads in < 200 ms p99; opening a message < 300 ms p99
  • New mail visible in the client within 5 s of SMTP acceptance (p95)
  • Search results in < 500 ms p95 over a mailbox of up to 1M messages
  • Availability 99.99% for read/compose; inbound SMTP can queue at the sender for hours, so 99.9% is acceptable there
  • Storage quota 15 GB per user by default

Out of scope (say it out loud)

  • Calendar, contacts and chat integration
  • Smart compose / AI summaries (mention as an extension only)
  • Mailing-list and bulk campaign sending (a separate campaign sender)
  • End-to-end encryption (S/MIME, PGP)

2 Estimates

Inbound messages~460k/s avg (peak ~1.5M/s)40B/day ÷ 86,400 ≈ 463k/s; ×3 for daily peak. About 50-60% of raw inbound is spam and much of it is rejected at SMTP time.
Outbound messages~58k/s avg5B/day ÷ 86,400 ≈ 58k/s
Mailbox reads~1M req/s500M DAU × ~150 API calls/day (list, open, sync polls) ÷ 86,400 ≈ 870k/s
New storage per day~1.5 PB/day before dedup~20B accepted (non-spam) messages × ~75 KB avg (most are 10-20 KB, attachments pull the mean up) ≈ 1.5 PB
Total stored mail~1-2 EB logical1B users × average ~1.5 GB used (quota 15 GB, most use far less). Attachment dedup and compression cut physical bytes significantly.
Metadata per message~1 KBids, headers summary, labels, thread id, snippet (~200 chars), flags, blob pointers
Search index size~20-30% of text sizeinverted index over subject, from, to, body text; attachment bodies indexed only for supported types

3 API

EndpointWhat it does
GET/v1/messages?label=INBOX&pageToken=…&limit=50List threads/messages for a label, newest first, cursor paginated
GET/v1/messages/{id}?format=fullFetch one message: headers, body parts, attachment metadata
POST/v1/attachments/uploadsGet a pre-signed URL to upload a large attachment directly to blob storage
POST/v1/messages/sendSend a composed message (Idempotency-Key header so a retry does not send twice)
POST/v1/messages/{id}/modifyAdd/remove labels, mark read, star, archive, trash
GET/v1/search?q=from:alice has:attachment invoiceFull-text search with operators over the caller's mailbox
GET/v1/history?startHistoryId=…Incremental sync: all mailbox changes since a history id
WS/v1/pushWeb client channel for 'mailbox changed, new history id = N' notifications
SMTPport 25 on MX hostsInbound mail from other providers (STARTTLS)

4 Data model

messagesBigtable / Cassandra (wide-column)
user_id (partition)message_id (time-ordered, clustering desc)thread_idfromtosubjectsnippetdatesizelabel_ids (set)flagsbody_blob_refattachments [{namesizemimeblob_hash}]

Partition by user_id so a mailbox lives together and inbox listing is a single-partition range scan. Very large mailboxes are split into sub-partitions by time bucket to avoid huge rows.

label_indexBigtable / Cassandra
(user_id, label_id) partitionmessage_date descthread_idmessage_id

Denormalised index per label so 'list INBOX' or 'list Starred' is a direct range read, not a filter over all mail.

threadsBigtable / Cassandra
user_idthread_idsubject_normalizedmessage_idslast_message_atparticipantsunread_count

Threading uses In-Reply-To/References headers first, then normalised subject + participants as a fallback.

blobsObject storage (GCS/S3-like, erasure coded)
key = sha256(content)encrypted bytesref_count

Bodies and attachments. Content-addressed so identical attachments sent to 10,000 people are stored once. Deletion is by reference counting plus a delayed garbage collector.

mailbox_historyBigtable / Cassandra
user_idhistory_id (monotonic per user)change_type (added/removed/labels)message_idlabel_ids

The per-mailbox change log that drives sync and push. Kept ~30 days; older clients do a full resync.

search_indexPer-user inverted index segments (Lucene-style) on sharded index servers
user_id → terms → posting lists of message_ids

Sharded by user id. Each user's index is small, so queries never fan out across shards.

users_quotaSpanner / sharded Postgres
user_idquota_bytesused_bytessettingsfilters

Strongly consistent counters for quota checks; filters (rules) applied at delivery time.

outbound_queueKafka + per-domain retry queue
message_idsenderrecipient_domainattemptnext_attempt_at

Partitioned by recipient domain so one slow domain does not block others.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALHTTPS / WSSMTP inboundSPF/DKIM + scanverdictstore bodywrite msg + historyMessageAddedlist / read / sendsearchread / modifyfetch bodysend / changesoutbound mailSMTP + DKIMindexupdate segmentsquerymailbox changedmobile pushWS pushWeb / mobile / IMAPclientInbound MX (SMTP)API gateway + authDelivery serviceMailbox API serviceSync / push serviceSearch serviceSpam + virus filterOutbound SMTP sendersKafka (mail events)Search indexersMailbox metadata(Bigtable)Blob store (bodies,files)Per-user search indexOther mail providersFCM / APNs
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Receive an email from outside

  1. 1The sender's mail server looks up our MX DNS records and opens SMTP on port 25 to an MX host (behind a TCP load balancer).
  2. 2During the SMTP conversation the MX checks the sending IP against reputation lists and rate limits, and rejects obvious spam before accepting the DATA (cheapest place to drop it).
  3. 3After DATA, it verifies SPF, DKIM signatures and the DMARC policy of the From domain, and runs virus scanning.
  4. 4The MX writes the raw message to a durable local queue (replicated) and only then replies '250 OK'. Before that, the sender still owns the message and will retry.
  5. 5The spam classifier scores the message (sender reputation, content model, user's own signals). The verdict is INBOX, SPAM or reject/quarantine.
  6. 6The delivery service resolves the recipient, applies user filters (labels, forwarding), stores the body in blob storage by content hash, and writes the message row, label index rows and a history entry in one per-user mutation.
  7. 7It publishes MessageAdded(user_id, message_id, history_id) to Kafka.
  8. 8Indexers update the user's search index; the sync service pushes 'new history id' to open web sessions and sends an FCM/APNs notification to phones.
  9. 9The client calls /history?startHistoryId=… and renders the new message, usually within 1-3 s.

Send an email with an attachment

  1. 1The client uploads the 10 MB attachment to blob storage with a pre-signed URL, getting back an upload id.
  2. 2The client calls POST /messages/send with an Idempotency-Key, recipients, body and the upload id.
  3. 3The mailbox API checks quota and outbound rate limits (anti-abuse: new accounts get low limits), writes the message to the sender's Sent label, and enqueues an outbound event.
  4. 4Internal recipients (same provider) are delivered directly through the delivery service, skipping SMTP.
  5. 5For external recipients, outbound workers group by recipient domain, sign with DKIM, choose an IP pool by sender reputation and open SMTP with STARTTLS to the domain's MX.
  6. 6On 4xx (temporary) errors the worker retries with exponential backoff for up to ~4-5 days; on 5xx it stops and generates a bounce message back to the sender's inbox.

Phone comes back online after 8 hours

  1. 1The app sends its last known historyId to GET /history.
  2. 2The sync service reads mailbox_history rows after that id for this user (a single-partition range scan).
  3. 3It returns a compact list of changes: messages added, removed, label changes.
  4. 4The app fetches only the headers/snippets of new messages, and full bodies lazily when opened.
  5. 5If the historyId is older than the retention window, the server returns 404 and the app does a full resync of recent mail.

7 Deep dives

Inbound pipeline: accept once, never lose, never duplicate

SMTP gives a clear ownership rule: until our MX replies 250 to the DATA command, the sending server owns the message and will retry. So the MX must persist the message durably (replicated queue or a write to the blob store plus a log) before it says 250. If the MX crashes after persisting but before replying, the sender retries and we receive the same message twice. To make delivery idempotent, the delivery service computes a key from (recipient, Message-ID header, hash of body) and does a conditional insert; duplicates become no-ops. Spam work is staged by cost: IP reputation and rate limits during the SMTP handshake (rejects most spam with almost no CPU), then SPF/DKIM/DMARC verification, then content and ML scoring after acceptance. DMARC is what stops someone spoofing 'From: hdfcbank.com': if the bank publishes p=reject and the message fails SPF and DKIM alignment, we reject or quarantine it. Per-user signals (this user marked similar mail as spam) adjust the final verdict. Everything after acceptance is asynchronous and retried from the durable queue, so a slow spam model adds latency but never loses mail.

Storage layout: metadata by user, bytes by content hash

Almost every query is scoped to one mailbox, so the primary key starts with user_id. Messages, a per-label index and a per-user history log live in a wide-column store (Bigtable at Google; Cassandra or HBase elsewhere). Listing the inbox is a range scan on (user_id, INBOX) ordered by date desc: one partition, a few KB, fast. Changing a label updates the message row, the old and new label index rows and appends a history row; Bigtable gives single-row atomicity, so either keep these in one row family or use a small per-user transaction log so partial updates are repaired. Bodies and attachments are large and immutable, so they go to object storage. Keying blobs by SHA-256 of the content means a 5 MB PDF sent to a 2,000-person company list is stored once. Use reference counts and a delayed garbage collector (days, not seconds) so a race between a new reference and a delete never destroys live data. Per-user encryption conflicts with global dedup; a common compromise is encrypting blobs with keys managed by the storage layer and limiting dedup to within a tenant or organisation.

Search: one small index per user, not one giant index

A global Elasticsearch index over a billion mailboxes would force every query to filter by user across huge posting lists and would make security bugs (leaking another user's mail) a real risk. Gmail-style search instead treats each mailbox as its own small index. Index servers are sharded by user_id; one shard holds the index segments for many users. A query goes to exactly one shard, so p95 stays low even with a million messages. Indexing is asynchronous: indexers consume MessageAdded and label-change events from Kafka and append to an in-memory segment that is flushed and merged periodically, so new mail is searchable within seconds. Labels and flags (is:unread, label:work) are small and change often, so they are better served from the metadata store and intersected with text results, rather than reindexing on every label change. Inactive users' indexes can be evicted to cheaper storage and rebuilt lazily on first search. For operators like from:, has:attachment and older_than:, keep structured fields in the index alongside text terms.

Outbound delivery and sender reputation

Sending is where large providers earn trust. Outbound workers sign every message with DKIM and publish SPF and DMARC records for our own domain, so receivers can verify us. Queues are partitioned by recipient domain: if a small company's mail server is down, its messages sit in a retry queue with exponential backoff (for example 1 min, 5 min, 30 min, then hourly up to ~5 days) without blocking delivery to Yahoo or Outlook. Workers respect each receiver's connection limits and throttling responses (421/451). Reputation is per IP and per domain, so the service uses separate IP pools: established users, new or suspicious accounts, and system mail. If a compromised account starts sending 50,000 messages an hour, per-account rate limits and anomaly detection stop it before our shared IPs are blocklisted, which would hurt every user. Bounces (5xx) are turned into a delivery-failure message in the sender's inbox. Internal mail never touches SMTP: the delivery service writes directly into the recipient's mailbox.

8 Trade-offs

Wide-column store vs relational database for mailboxes

Mailboxes are a natural fit for a wide-column store: huge total size, simple access by (user, label, time), high write volume, no cross-user joins. Sharded Postgres also works (shard by user_id) and gives transactions for label changes, but resharding exabytes is painful. Use a strongly consistent store (Spanner, Postgres) only for small, critical data like quotas, account settings and filters.

Push vs poll for new mail

Polling every 30 s from 500M clients is ~17M req/s of mostly empty answers. Instead the server pushes a tiny 'mailbox changed, history id N' signal over WebSocket (web) or FCM/APNs (mobile), and the client pulls the actual changes. The payload stays small, the client always uses the same sync path, and a missed push is fixed on the next app open.

Content-hash dedup vs per-message blobs

Dedup saves a lot of storage for attachments that go to many people, but adds reference counting, slower deletes and tension with per-user encryption keys. A practical choice: dedup attachments above ~100 KB within a domain or tenant; store small bodies inline or per-message.

Thread by headers vs by subject

References/In-Reply-To headers are accurate when clients set them. Subject-only threading merges unrelated mails titled 'Hi'. Use headers first, subject plus participants as a fallback, and never merge across a long time gap.

9 Bottlenecks

Huge mailboxes and hot partitions

A user with 5M messages or a shared support inbox receiving 100 mails/s creates a hot, oversized partition. Fix: split the user's data into time-bucketed sub-partitions (user_id, month), cap rows per partition, and rate-limit delivery per mailbox with a buffer queue.

Spam storms

A botnet campaign can multiply inbound volume 5-10x in minutes. Reject as early and as cheaply as possible (IP reputation, connection limits during SMTP), autoscale the stateless MX tier, and let the ML scoring tier fall back to a cheaper model when overloaded instead of building an unbounded queue.

Search index rebuilds

Changing the analyzer or tokenizer (for example adding Hindi or Tamil stemming) means re-indexing exabytes. Do it lazily per user (rebuild on next search, background for active users first) and keep both index versions during the migration.

10 Failure handling

Delivery service or metadata store is down

MX servers keep accepting mail into their durable queue as long as it has space, and the backlog drains when storage recovers. If the queue fills, MX replies 4xx (temporary failure), and senders retry for days per the SMTP standard. Mail is delayed, not lost.

Spam model service is slow or failing

Do not block delivery forever. After a timeout, fall back to rule-based and reputation scoring, deliver, and re-score asynchronously; a later 'move to spam' is acceptable. Never fail open for virus scanning on attachments: hold those until scanning succeeds.

Push notification lost

FCM/APNs are best effort. Correctness comes from the history log: any app open, pull-to-refresh or periodic background sync catches up from the last history id.

A whole zone fails

Metadata and blobs are replicated across zones (3 replicas or erasure coding), so reads continue. MX hosts in other zones and regions are listed in DNS with MX priorities, so senders fail over automatically.

11 What impresses

  • Explains SMTP ownership: the sender retries until 250, so accept only after a durable write, and dedupe on Message-ID
  • Rejects most spam during the SMTP handshake using cheap IP reputation before spending CPU on content
  • Chooses per-user search indexes and explains why a global index is slower and riskier
  • Uses a per-mailbox history id for sync instead of 'send the whole inbox' or timestamps (which break with clock skew)
  • Partitions outbound queues by recipient domain so one dead server does not block everything
  • Protects shared IP reputation with outbound rate limits for new or compromised accounts

12 Mistakes

  • Storing attachments inside the database row
  • Designing one global search index filtered by user_id
  • Polling for new mail from every client every few seconds
  • Ignoring spam entirely, or treating it as a small add-on, when it is over half of inbound traffic
  • Replying 250 to SMTP before the message is durably stored
  • Retrying outbound mail forever, or not at all, instead of backoff with a bounce after a few days
  • Not separating internal delivery from external SMTP

13 Follow-ups

Q1How would you support 'undo send' for 30 seconds?
Q2How do you implement scheduled send at a future time?
Q3How do you migrate a user's mailbox to another shard with no downtime?
Q4How would IMAP clients map to labels, which are not folders?
Q5How would you add AI features like smart reply or summaries without blowing up cost?
Q6A company wants its mail stored only in India for compliance. What changes?
Q7How do you enforce the 15 GB quota when the same attachment is deduplicated?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free