Gmail API Push Notifications: Pub/Sub Architecture Explained

Engineers and former hiring managers from FAANG-tier companies. Combined 500+ technical interviews conducted and 1,200+ hours of coaching candidates.

"Design a system that reacts to new email in near real time" is a deceptively deep system design interview prompt, and Gmail's own push-notification API is one of the cleanest real-world answers to it. It is a textbook example of event-driven, webhook-based design built on a message broker — so understanding it teaches you the exact patterns interviewers probe in any "design a notification or webhook system" round. This deep dive walks the real Gmail API Pub/Sub push notifications architecture end to end, using only the actual API surface: users.watch, users.history.list, and users.stop.

Polling vs Push: Why Gmail Built This

The naive way to detect new mail is to poll: hit the API every few seconds and ask "anything new?" Polling is simple but wasteful. At low frequency it is slow — you might wait 30 seconds to see a message. At high frequency it burns quota and CPU on requests that almost always return "nothing changed." Multiply that by millions of mailboxes and the cost is enormous.

Push inverts the flow: the source tells you the instant something changes. Gmail publishes a notification to a Google Cloud Pub/Sub topic the moment a watched mailbox changes, so you do zero idle work and learn about changes in roughly a second. The trade-off is operational complexity — you now run a topic, a subscription, and a webhook, and inherit the hard parts of distributed messaging (duplicates, ordering, retries, expiry). That trade-off, push complexity for freshness and efficiency, is exactly the discussion an interviewer wants to hear.

The Components

There are five moving parts. Get the responsibilities straight and the rest follows.

The Notification Payload: a Pointer, Not the Data

The single most important thing to understand — and the detail that separates someone who has read the docs from someone who hasn't — is that the notification carries no email content. The Pub/Sub message data field is base64-encoded JSON with exactly two fields:

{
  "emailAddress": "[email protected]",
  "historyId": "9876543210"
}

That is the whole payload. emailAddress identifies the mailbox; historyId is a monotonic cursor marking the mailbox's new state. The notification is a doorbell, not a delivery — "something changed, the new watermark is this." You pull the actual changes yourself.

You do that by calling users.history.list with startHistoryId set to the last historyId you stored for that mailbox — not the one in the notification. Gmail returns the ordered list of changes (messages added, deleted, labels added or removed) between your stored cursor and now; you apply them, then advance your stored historyId. This stored-cursor design is what makes the system resilient: even if you miss notifications, the next one lets you replay everything since your last known position.

Key insight: always diff from your persisted historyId, never from the value in the latest notification. The notification's historyId is just "the newest watermark." Your stored one is "the last thing I actually finished." Diffing from the stored cursor is what makes missed, duplicated, or out-of-order notifications harmless.

Architecture Diagram

Here is the full flow, from arming the watch to durable storage:

  ┌─────────────┐   1. users.watch(label, topic)   ┌──────────────────┐
  │ Your Service│ ───────────────────────────────▶ │   Gmail API      │
  │ (registrar) │ ◀─── returns {historyId, exp} ─── │  (the mailbox)   │
  └─────────────┘                                   └────────┬─────────┘
                                                             │ 2. mailbox
                                                             │    changes
                                                             ▼
                                              ┌──────────────────────────┐
                                              │  Cloud Pub/Sub  TOPIC    │
                                              │  msg = {emailAddress,    │
                                              │         historyId}       │
                                              └────────────┬─────────────┘
                                                           │ 3. fan-out
                                                           ▼
                                              ┌──────────────────────────┐
                                              │  Pub/Sub  SUBSCRIPTION   │
                                              │  (push HTTPS  |  pull)   │
                                              └────────────┬─────────────┘
                                                           │ 4. deliver + OIDC JWT
                                                           ▼
                                              ┌──────────────────────────┐
                                              │   YOUR WEBHOOK (HTTPS)   │
                                              │  verify token → ack 200  │
                                              └────────────┬─────────────┘
                                                           │ 5. history.list(startHistoryId =
                                                           │       stored cursor)
                                                           ▼
                                              ┌──────────────────────────┐
                                              │   Gmail API  history     │  ◀── returns the delta
                                              └────────────┬─────────────┘
                                                           │ 6. apply changes,
                                                           │    advance cursor
                                                           ▼
                                              ┌──────────────────────────┐
                                              │   YOUR DATABASE          │
                                              │  messages + lastHistoryId│
                                              └──────────────────────────┘

Read it as a loop: watch() arms it → Gmail publishes to the topic → the subscription delivers to your webhook → you call history.list from your stored cursor → you write to your DB and move the cursor forward. Every arrow is a place where an interviewer can ask "what happens if this fails?"

Idempotency: Duplicates and Out-of-Order Delivery

Cloud Pub/Sub guarantees at-least-once delivery. That implies two things you must design for: the same notification can arrive more than once, and notifications can arrive out of order. If your handler is not idempotent, a redelivered message could double-process a change or overwrite newer state with older.

The defense is the stored-cursor pattern plus idempotent writes:

Notice the architecture turns the hard "exactly-once" problem into an easier "at-least-once + idempotent" one — worth stating explicitly in an interview, since true exactly-once delivery across a network is effectively impossible.

Ack Deadlines and Retries

Pub/Sub holds each message as outstanding until your subscriber acknowledges it. For a push subscription, returning an HTTP 2xx from your webhook is the ack; any other status (or a timeout) is a nack, and Pub/Sub redelivers after a backoff. For pull, you call acknowledge explicitly. Each subscription has an ack deadline (commonly tuned to seconds to minutes); if you do not ack within it, the message is considered unacknowledged and redelivered.

This has a clean design implication: ack fast, work durably. Don't do the slow history.list call and database writes inside the request you must ack. A robust pattern is to validate the notification, drop it onto an internal queue, ack immediately, and let workers do the fetch-and-persist. That keeps you under the ack deadline and protects you from a slow Gmail call triggering a storm of redeliveries. Pair retries with a dead-letter topic so a permanently poisonous message doesn't loop forever.

The 7-Day Watch Expiry

A watch is not permanent. It expires after seven days, and once it lapses Gmail simply stops publishing — no error, just silence, which makes this a favorite source of "why did notifications mysteriously stop" bugs. users.watch() returns the expiration timestamp, so the fix is to re-call watch() on a schedule well before expiry; once per day is the standard recommendation, which both refreshes the window and self-heals any transient gap. When you want to stop receiving notifications for a mailbox entirely, call users.stop(). Treat watch renewal as a first-class cron job, not an afterthought — its absence is invisible until mail stops flowing.

Security: Lock Down the Endpoint

Your webhook is a public HTTPS URL, so you must assume the open internet will find it. Two layers protect it:

And because the payload is only a pointer, even a spoofed notification can't leak data: the worst it does is make you call history.list, which is authenticated and returns only that mailbox's real changes. Treating the body as an untrusted trigger and re-fetching from the source API is the defensive default.

Scaling

The design scales naturally because the broker absorbs spikes. A few notes for the "now make it handle millions of mailboxes" follow-up:

What Interviewers Probe in a "Design a Notification/Webhook System" Round

Gmail's design maps almost one-to-one onto the generic webhook-system question. If you can explain this flow, you can answer that round. Here's what the interviewer is really listening for:

Walking through Gmail's watch → topic → subscription → webhook → history.list → DB pipeline gives you a concrete, defensible reference design for all of those — which beats reciting abstract principles. For more on structuring this kind of answer, see our guide on designing a notification system, and the companion piece on using AI in system design interviews as a backup brain while you reason out loud.

Rehearse the "design a webhook system" round out loud

CoPilot Interview is a native desktop assistant for Windows and macOS that surfaces the trade-offs you forgot — at-least-once delivery, idempotency, ack deadlines — so you practice explaining the design yourself. Start on the permanent free tier.

Explore the System Design Tool →

FAQ

How do Gmail API push notifications work?

You call users.watch() on a mailbox, naming a Cloud Pub/Sub topic and the labels you care about. When a matching mailbox change occurs, Gmail publishes a small message to that topic containing the emailAddress and a new historyId. A Pub/Sub subscription delivers that message to your HTTPS webhook, and your service calls users.history.list starting from the last historyId you stored to pull the actual changes. There is no email body in the notification — it is only a pointer telling you to go fetch.

What is in a Gmail push notification payload?

The Pub/Sub message data is a base64-encoded JSON object with just two fields: emailAddress (the mailbox that changed) and historyId (a cursor marking the new state of that mailbox). It deliberately contains no message content. You diff against the historyId you previously persisted by calling users.history.list, which returns the added, deleted, and label-changed messages since that point.

How do you handle duplicate or out-of-order Gmail notifications?

Cloud Pub/Sub guarantees at-least-once delivery, so duplicates and reordering are normal and must be designed for. Make processing idempotent: store the largest historyId you have fully processed and never move it backward. Because users.history.list returns the full delta from your stored cursor, replaying an older notification simply re-fetches changes you already applied, so applying updates idempotently (upserts keyed by message ID) keeps mailbox state correct.

How long does a Gmail watch last and how do you renew it?

A watch expires after seven days, and Gmail stops publishing once it lapses. The expiration timestamp is returned by users.watch(), so you schedule a re-call of watch() well before it — daily is the common recommendation — to refresh the subscription. To stop receiving notifications entirely, call users.stop(). Renewing also re-establishes delivery if your topic or permissions ever changed.

How do you secure a Gmail Pub/Sub push webhook?

Verify the OIDC JWT that Pub/Sub attaches to each push request: confirm the token's signature against Google's public keys, check the aud claim matches your endpoint, and confirm the service account email is the one you authorized. Serve the endpoint over HTTPS, reject anything unauthenticated, and grant the Gmail API service account publish rights on your topic so only Gmail can post to it. Treat the historyId as untrusted input and re-fetch via the API rather than trusting any body content.

Related Resources
System Design Interview Tool
AI-powered system design help in real time.
Design a Notification System
Fan-out, delivery guarantees, and queues.
AI for System Design
Use AI as a backup brain, not a script.
Free AI Interview Assistant
Permanent free tier on Windows & macOS.
Coding Interview Help
AI-assisted algorithm and code rounds.
CoPilot Interview Home
Real-time desktop interview assistant.