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 and Gnaw drafts an Apps Script starting point. Review its permissions and test with sample data before using it. Free, no login.