Skip to content
Skip to main content

Gmail users.messages.list: maxResults, nextPageToken

Last updated:

Paging through a Gmail mailbox sounds simple until you hit the details: messages.list returns IDs only, every page needs a token round-trip, and keeping a local copy current means tracking a historyId that expires. The Nylas Email API handles the same job with full message objects behind one page_token cursor, plus message.created webhooks in place of historyId polling.

How do Gmail API nextPageToken and maxResults work?

Section titled “How do Gmail API nextPageToken and maxResults work?”

The Gmail API maxResults parameter sets the page size for users.messages.list: it defaults to 100 message IDs and accepts up to 500. The users.messages.list reference says “This field defaults to 100. The maximum allowed value for this field is 500.” When more matching messages exist, the response includes a nextPageToken. Pass it back as pageToken and loop until the token is absent.

The important rule: maxResults is the page size, not a total cap. A mailbox with 10,000 matching messages needs at least 20 sequential requests at maximum page size. According to the Gmail API messages.list reference, each call costs 5 quota units and returns message IDs only, never subjects or bodies.

The Python loop below collects every message ID in a mailbox with the official google-api-python-client library. It runs 20 times for a 10,000-message inbox, and you still need a separate messages.get call (20 quota units each) for every body you want to read.

from googleapiclient.discovery import build
service = build("gmail", "v1", credentials=creds)
all_messages = []
page_token = None
while True:
response = service.users().messages().list(
userId="me",
maxResults=500,
pageToken=page_token,
).execute()
all_messages.extend(response.get("messages", []))
page_token = response.get("nextPageToken")
if not page_token:
break
print(f"Fetched {len(all_messages)} message IDs")

That’s 18 lines to collect IDs. Fetching 10,000 full bodies afterward costs about 200,000 quota units in messages.get calls, which is why quota planning matters before a full sync (see Gmail API quotas).

Do messages get skipped if they’re deleted while paginating?

Section titled “Do messages get skipped if they’re deleted while paginating?”

Google doesn’t document how users.messages.list behaves when messages are deleted, added, or relabeled between page requests. The reference describes pageToken only as a “Page token to retrieve a specific page of results in the list,” with no snapshot guarantee, and resultSizeEstimate as an “Estimated total number of results.” Treat a long pagination run as a view that can drift: a full pass over 10,000 messages at 500 per page takes 20 requests, and the mailbox can change between any two of them.

The pattern Google documents for this is to pair the list pass with history. The Gmail sync guide tells apps to store “the historyId of the most recent message (the first message in the list response)” during a full sync. Record that ID before you request page 2, finish the nextPageToken loop, then call users.history.list with it as startHistoryId. Messages added or deleted while the loop ran come back as messageAdded and messageDeleted records, as long as the stored ID is still inside the history window, so a shifted page boundary doesn’t leave your copy out of date.

Two practical rules follow. Tolerate IDs from an earlier page that no longer resolve when you call messages.get, and don’t use resultSizeEstimate as an exact count for progress bars or completeness checks.

How does Gmail incremental sync work with historyId?

Section titled “How does Gmail incremental sync work with historyId?”

Gmail incremental sync tracks mailbox changes through a monotonically increasing historyId. You store the ID from your last sync, then call users.history.list with startHistoryId to fetch only messages added, deleted, or relabeled since that point. Each call costs 2 quota units, 60% cheaper than a messages.list call at 5 units.

The Gmail API sync guide recommends this as the primary pattern for keeping a local copy current. The historyTypes parameter filters by change type: messageAdded, messageDeleted, labelAdded, and labelRemoved.

The function below pages through history records since a checkpoint and returns the new historyId to store for the next run. Like messages.list, the history endpoint paginates with nextPageToken, so even the delta path needs a token loop.

def get_changes_since(service, start_history_id):
"""Fetch all mailbox changes since the given historyId."""
changes = []
page_token = None
while True:
response = service.users().history().list(
userId="me",
startHistoryId=start_history_id,
historyTypes=["messageAdded", "messageDeleted"],
pageToken=page_token,
).execute()
changes.extend(response.get("history", []))
page_token = response.get("nextPageToken")
if not page_token:
break
new_history_id = response.get("historyId")
return changes, new_history_id

There’s a catch: the sync guide says history records are “typically available for at least one week and often longer,” but the window can be much shorter. If your stored historyId is outside the available range, history.list returns 404 Not Found, and your code must fall back to a full re-pagination. Every production sync client needs both code paths.

What goes wrong when you build Gmail sync yourself?

Section titled “What goes wrong when you build Gmail sync yourself?”

A production Gmail sync client has to handle the OAuth token lifecycle, the expired-history fallback, rate limiting, and partial failures in code you maintain. What starts as a 20-line pagination loop typically grows to 80-120 lines before logging, persistence, or multi-account support. The recurring failure points:

  • OAuth token management. Gmail access tokens expire every 3,600 seconds. The sync loop needs a refresh callback, expired-token detection, and a retry for the failed request.
  • Expired historyId fallback. When history.list returns 404, the client must discard the delta path and run a full pagination instead. Two code paths, both of which have to work.
  • Rate limiting. New Gmail API projects get 6,000 quota units per minute per user. A large sync needs client-side throttling and exponential backoff on 403 rateLimitExceeded and 429 Too Many Requests responses.
  • Partial page failures. A network error mid-pagination leaves you with half the results and a decision: retry from the start, or from the last good token? Either way you’re tracking state.
  • Setup overhead. Before any code runs, you need a Google Cloud project, an OAuth consent screen, a client ID and secret, and a redirect URI. That’s 15-20 minutes of console configuration, plus a verification review if your app requests restricted scopes.

How do you paginate Gmail messages with the Nylas API?

Section titled “How do you paginate Gmail messages with the Nylas API?”

The Nylas Email API replaces the two-step list-then-get pattern with one request. GET /v3/grants/{grant_id}/messages returns full message objects (subject, sender, body, folders), 50 per page by default and up to 200 with the limit parameter. When more results exist, the response includes next_cursor, which you pass back as page_token.

That cuts a 10,000-message Gmail pass from 20 ID-only messages.list calls plus 10,000 messages.get calls to 50 requests at limit=200. The API refreshes the grant’s OAuth token, so the loop has no token-expiry branch. The curl, Node.js, and Python samples below show the loop.

The Messages API returns paginated responses. When there are more results, the response includes a next_cursor value. Pass it back as page_token to get the next page:

Keep paginating until the response comes back without a next_cursor.

If Google throttles a page, the request returns a 429 right away. Wait the number of seconds in Retry-After when the response includes it, otherwise back off as shown in handle rate limit errors. For large mailboxes, the Messages API reference recommends a limit of 20 plus query filters if you keep hitting 429 errors.

The same loop runs unchanged against Microsoft, Yahoo, iCloud, IMAP, and EWS accounts, because the cursor contract belongs to the unified API rather than to each provider. For the Gmail-specific listing walkthrough, including label filters and Gmail search operators, see How to list Google email messages.

Webhooks replace the historyId checkpoint entirely. Subscribing one HTTPS endpoint to the message.created trigger delivers a notification when a new message arrives, instead of a polling loop that fires 288 times per day per inbox at a 5-minute interval. There’s no Cloud Pub/Sub topic to create and no users.watch channel to renew every 7 days.

Each notification carries the message object itself, up to a 1 MB payload. Larger messages arrive as message.created.truncated with the body removed, so your handler fetches the full message by ID. The message.updated and message.deleted triggers report changes to existing messages, such as label moves and deletions, and one webhook covers every connected account, whatever the provider.

The real-time webhooks recipe covers the full setup: creating the webhook, responding to the challenge request, and verifying signatures. Using webhooks with Nylas documents truncation and retries, and webhook retry handling covers missed deliveries.

How do other email providers handle pagination?

Section titled “How do other email providers handle pagination?”

Each provider ships a different pagination contract, which is the main reason multi-provider sync code forks. Gmail uses an opaque nextPageToken with a 500-ID page cap. Microsoft Graph returns an @odata.nextLink URL the client follows verbatim, at up to 1,000 items per page. IMAP servers return every matching UID from UID SEARCH in one response. EWS pages with a numeric offset.

ProviderPagination methodCursor typeMax page size
Gmail APInextPageTokenOpaque string500
Microsoft Graph@odata.nextLinkFull URL1,000
IMAP (Yahoo, iCloud, hosted)UID SEARCH + fetch rangesSequence numbersNo page limit
EWS (legacy Exchange)IndexedPageItemViewNumeric offset1,000

The unified page_token / next_cursor contract abstracts all four, so one pagination loop covers every connected account. Provider-specific listing guides: Microsoft, Yahoo, iCloud, IMAP, and EWS.