Process large datasets without timeouts
Chunk Northwind work across multiple executions — survive Apps Script's 6-minute limit.
Published Aug 28, 2025
Every Apps Script execution has a hard ceiling — six minutes for a consumer account, thirty for Workspace. A script that processes a few hundred rows never notices it. A script that has to work through tens of thousands of rows hits the ceiling, stops mid-run, and leaves the job half done.
You cannot raise the limit, so the answer is to stop trying to finish in one run. Split the work into chunks small enough to complete comfortably, and use a saved cursor so each run picks up exactly where the last one stopped. This is the continuation-token pattern.
The pattern: continuation token
The script processes a fixed batch of rows per execution, records how far it got, and a recurring trigger calls it again until the data runs out.
const BATCH = 500; // Rows per run — small enough to finish well under the limit.
function processLargeDataset() {
const props = PropertiesService.getScriptProperties();
// Resume from the saved cursor, or start at row 0 on the first run.
const start = parseInt(props.getProperty('cursor') || '0');
const sheet = SpreadsheetApp.openById('1abcBigDataId').getSheets()[0];
// Read one batch in a single call — start + 2 skips the header row.
const data = sheet.getRange(start + 2, 1, BATCH, sheet.getLastColumn()).getValues();
// No rows left to read — the job is complete. Clean up and stop.
if (data.length === 0 || data[0][0] === '') {
props.deleteProperty('cursor'); // Reset so a future run starts fresh.
cleanupTrigger(); // Remove the trigger — nothing left to do.
return;
}
// Process this batch in memory, then advance the cursor.
for (const row of data) processOne(row);
props.setProperty('cursor', String(start + BATCH));
}
function startProcessing() {
// Clear any stale cursor, then create the trigger that drives the job.
PropertiesService.getScriptProperties().deleteProperty('cursor');
ScriptApp.newTrigger('processLargeDataset').timeBased().everyMinutes(5).create();
}
function cleanupTrigger() {
// Delete only this job's trigger — leave any other project triggers alone.
ScriptApp.getProjectTriggers()
.filter((t) => t.getHandlerFunction() === 'processLargeDataset')
.forEach((t) => ScriptApp.deleteTrigger(t));
}
How it works
The pattern has three moving parts that together turn one impossible run into a series of small, safe ones.
- The batch. Each invocation processes a fixed number of rows — here 500 — chosen so a single run finishes well inside the execution limit.
- The cursor. A Script Property records the next row to process. Because it is durable storage, the value survives between separate executions.
- The self-cleaning trigger. A time-based trigger calls the function every few minutes, and the function deletes that trigger once the data is exhausted.
The first run starts at row 0; each later run reads its cursor, processes the next batch, and advances it. When a read comes back empty, the work is done.
Choosing a batch size
The right batch size balances two costs: too small and the job needs many slow runs; too large and a run risks timing out before it saves its cursor.
| Per-row work | Suggested batch | Reason |
|---|---|---|
| Pure in-memory transforms | 1,000–2,000 | No network — rows are cheap |
| One API or Sheets call per row | 100–300 | Round trips dominate the time |
| Heavy work (AI calls, file I/O) | 20–50 | A few slow rows can fill six minutes |
Start conservative and measure. If a run finishes in well under a minute, raise the batch; if it ever times out, lower it. The cursor makes a timed-out run harmless — the next run simply retries from the last saved position.
Common mistakes
- Forgetting to save the cursor. If the run ends without writing
cursor, the next run restarts from the beginning and reprocesses everything. - No exit condition. Without the empty-read check, the trigger fires forever and the cursor walks off the end of the sheet.
- Leaving the trigger behind. A finished job whose trigger is never deleted keeps waking up, burning trigger quota for nothing.
- A batch that is too large. If a run times out before saving its cursor, that batch’s progress is lost and re-done — keep batches comfortably small.
- Non-idempotent per-row work. A timed-out run is retried, so
processOnemay see the same row twice — make it safe to run more than once. - Reading the whole sheet up front. Pulling every row into memory defeats the point; read only the current batch’s range each run.