Skip to content
// field note2026 · Jul5 min read

A better way to page through Gmail in Apps Script.

The trick isn’t reading faster — it’s reading less: filter server-side, page in chunks, and remember where you stopped.

The naive version times out

The first version of every Gmail script looks like this: getInboxThreads(), loop over all of them, do something. On a small mailbox it’s fine. On a mailbox with tens of thousands of threads it either hits the 6-minute execution limit or trips a Gmail read-quota error, and it does so unpredictably — it worked in testing and fails in production because production has more mail.

Two things are wrong: you’re asking for far more than you need, and you’re asking for it all at once.

Filter on the server with a search query

Do not fetch the inbox and filter in JavaScript. Push the filter into Gmail with the same query syntax you type in the search box: label:, after:, from:, is:unread, has:attachment. GmailApp.search() runs that query on Google’s side and returns only the matching threads, which is dramatically less data to move.

search() also takes a start offset and a count, which is the pagination primitive the iterator hides from you. Read a page, process it, advance the offset:

const QUERY = 'label:invoices is:unread';
const PAGE = 100; // threads per call; keep well under Gmail's 500 ceiling

function processInvoices() {
  let start = 0;
  while (true) {
    const threads = GmailApp.search(QUERY, start, PAGE);
    if (threads.length === 0) break;

    // Batch-read messages for the whole page in one call, not per-thread.
    const messagesByThread = GmailApp.getMessagesForThreads(threads);
    messagesByThread.forEach((messages) => handleThread(messages));

    start += threads.length;
  }
}

Batch the message reads

Calling thread.getMessages() inside the loop makes one Gmail read per thread — the exact pattern that burns quota. GmailApp.getMessagesForThreads() takes the whole page of threads and returns their messages in a single call. One request per page instead of one per thread is often a 100× reduction in read operations, and read quota is the limit you actually hit first.

Checkpoint the cursor for time-driven runs

A big backlog won’t finish inside one 6-minute execution. Rather than fighting the limit, embrace it: process a bounded number of threads per run, save where you stopped in PropertiesService, and let a time-driven trigger resume next time. Because the query is is:unread and you mark threads read as you go, the cursor is really just “whatever still matches” — the set shrinks each run until it’s empty.

const MAX_PER_RUN = 300; // stay comfortably inside the 6-min limit

function drainInvoices() {
  const props = PropertiesService.getScriptProperties();
  let done = 0;

  while (done < MAX_PER_RUN) {
    const threads = GmailApp.search('label:invoices is:unread', 0, 100);
    if (threads.length === 0) {
      props.deleteProperty('cursor'); // caught up
      return;
    }
    GmailApp.getMessagesForThreads(threads).forEach((messages) => {
      handleThread(messages);
      messages[0].getThread().markRead(); // shrinks the next search
    });
    done += threads.length;
  }
  props.setProperty('cursor', String(Date.now())); // more to do next run
}

The shape that scales

Filter server-side so you move the least data. Page the search instead of loading everything. Batch the message reads so quota lasts. Bound the work per run and let a scheduled trigger resume. None of it is exotic — it’s just the difference between a script that works on your inbox and one that works on everyone’s.

FAQ

Why does my Gmail script hit a quota or timeout error only in production?
Production mailboxes have more mail. getInboxThreads() with no paging, plus a getMessages() call per thread, scales with inbox size and eventually crosses the 6-minute execution limit or the Gmail read quota. Page the search and batch message reads to make cost independent of total inbox size.
How do I paginate Gmail in Apps Script?
Use GmailApp.search(query, start, max). Pass a start offset and a page size (keep it well under the 500 ceiling), process the page, then advance start by the number returned until search() comes back empty.
How do I process a huge backlog that won’t finish in one run?
Bound the work to a few hundred threads per execution, save a cursor in PropertiesService, and run it on a time-driven trigger. If your query filters on is:unread (or a label you remove as you go), the matching set shrinks each run until it’s empty.

Skip the boilerplate.

Describe the automation in a sentence and Gnaw writes the Apps Script — triggers, pagination, edge cases handled. Free, no login.