Each Apps Script run can last six minutes, and custom functions get 30 seconds. When a script hits that cap you see "Exceeded maximum execution time." The fix is almost always to read and write the sheet in batches with getValues and setValues, and if the job is still too big, to split it into chunks that save their place and continue on the next run.
The first time I saw "Exceeded maximum execution time," it was on a script that had worked fine for a year. Nothing in the code had changed. The sheet had just grown. That's how most people meet Apps Script's limits: quietly, when the business gets busier. Here are the limits that matter in practice and the patterns I use to stay inside them.
The limits that actually bite
Google publishes the full list on its Apps Script quotas page. These are the ones I run into most. Google can change them, so check that page before you design around a number.
| Limit | Free Gmail account | Google Workspace |
|---|---|---|
| Time per execution | 6 minutes | 6 minutes |
| Custom function time per call | 30 seconds | 30 seconds |
| Total trigger runtime per day | 90 minutes | 6 hours |
| URL Fetch calls per day | 20,000 | 100,000 |
| Email recipients per day | 100 | 1,500 |
| Triggers per user per script | 20 | 20 |
Two things to notice. The six-minute cap is the same for everyone, so paying for Workspace won't fix a script that times out. And the daily trigger runtime is shared across every triggered run of that user's scripts, so a script that runs every five minutes and takes 30 seconds each time uses a lot of it.
Fix one: stop touching cells one at a time
Every call to getValue, setValue, getRange or setBackground is a round trip to the sheet. One is fast. Ten thousand in a loop is not. This is the slow version I see most often:
// Slow: two calls to the sheet per row
function markOverdueSlow() {
const sheet = SpreadsheetApp.getActive().getSheetByName('Invoices');
const today = new Date();
for (let row = 2; row <= sheet.getLastRow(); row++) {
const due = sheet.getRange(row, 3).getValue();
if (due < today) sheet.getRange(row, 4).setValue('Overdue');
}
}
And the batched version, which does the same thing with one read and one write:
// Fast: one read, one write
function markOverdue() {
const sheet = SpreadsheetApp.getActive().getSheetByName('Invoices');
const lastRow = sheet.getLastRow();
if (lastRow < 2) return;
const dueDates = sheet.getRange(2, 3, lastRow - 1, 1).getValues();
const statusRange = sheet.getRange(2, 4, lastRow - 1, 1);
const statuses = statusRange.getValues();
const today = new Date();
for (let i = 0; i < dueDates.length; i++) {
if (dueDates[i][0] instanceof Date && dueDates[i][0] < today) {
statuses[i][0] = 'Overdue';
}
}
statusRange.setValues(statuses);
}
The work happens in memory, which is fast, and the sheet only gets two calls no matter how many rows there are. Most timeout problems I'm asked to fix disappear with this change alone.
The same goes for formatting. Build a 2D array of colors and use setBackgrounds once instead of setBackground per row. Avoid SpreadsheetApp.flush() inside loops unless you need the sheet updated mid-run.
Fix two: split the job and keep your place
Some jobs are genuinely too big for six minutes, even when batched. Generating a PDF per customer, calling an API once per row or copying hundreds of files all take real time per item. For those, I process a chunk, save where I stopped and schedule the next run.
PropertiesService stores the position between runs, and a one-time trigger picks up the rest:
const BATCH_TIME_MS = 4.5 * 60 * 1000; // stop well before 6 minutes
function processQueue() {
const props = PropertiesService.getScriptProperties();
const start = Number(props.getProperty('nextRow') || 2);
const sheet = SpreadsheetApp.getActive().getSheetByName('Queue');
const lastRow = sheet.getLastRow();
const began = Date.now();
let row = start;
while (row <= lastRow && Date.now() - began < BATCH_TIME_MS) {
const values = sheet.getRange(row, 1, 1, 3).getValues()[0];
doSlowWork(values); // your per-row work: a PDF, an API call, etc.
sheet.getRange(row, 4).setValue('Done');
row++;
}
clearContinuationTriggers();
if (row <= lastRow) {
props.setProperty('nextRow', String(row));
ScriptApp.newTrigger('processQueue').timeBased().after(60 * 1000).create();
} else {
props.deleteProperty('nextRow');
}
}
function clearContinuationTriggers() {
ScriptApp.getProjectTriggers()
.filter(t => t.getHandlerFunction() === 'processQueue' && t.getEventType() === ScriptApp.EventType.CLOCK)
.forEach(t => ScriptApp.deleteTrigger(t));
}
function doSlowWork(values) {
// placeholder for the real per-row work
}
A few details matter here. I stop at four and a half minutes, not five fifty-nine, because the last item might take longer than expected. I delete the old continuation trigger each run, because triggers that fired don't always clean themselves up and you only get 20 per script. And I write "Done" per row, so even if something kills the run, nothing gets processed twice.
If the per-row work is an API call, check whether the service has a bulk endpoint first. One call that returns 500 records beats 500 calls, and it saves your URL Fetch quota. UrlFetchApp.fetchAll() can also send several requests in parallel.
Fix three: custom functions that take too long
A custom function like =GET_PRICE(A2) gets 30 seconds per call, and when it's copied down 2,000 rows, the sheet calls it 2,000 times. That's slow even when each call is fast.
Two things help. Make the function accept a whole range and return a whole column, so one formula at the top does the work:
/**
* Doubles every number in a range.
* @param {number[][]} input A range of numbers.
* @return The doubled values.
* @customfunction
*/
function DOUBLE_ALL(input) {
if (!Array.isArray(input)) return input * 2;
return input.map(row => row.map(v => (typeof v === 'number' ? v * 2 : '')));
}
Use it as =DOUBLE_ALL(A2:A2000) in one cell. And if the function calls an API, cache results with CacheService so repeat lookups don't hit the network again.
If it still can't fit in 30 seconds, it shouldn't be a custom function. Turn it into a menu item or a trigger that writes values into the sheet.
Two runs at once
When a form trigger and a time trigger can both start the same script, two copies can run at the same moment and both process the same row. That's how duplicate emails and double-charged invoices happen. LockService makes the second run wait or give up:
function safeRun() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) return; // someone else is running; try next time
try {
// do the work
} finally {
lock.releaseLock();
}
}
I put this around anything that sends, charges or moves data.
When it's time for something other than Apps Script
Apps Script covers a lot of ground for a small business. If you're processing hundreds of thousands of rows nightly, or need something to run longer than six minutes without chunking, it may be the wrong tool, and a small cloud function or a real database is a better fit. I'd rather say that up front than build something that fights its limits every day.
If your script is timing out
If you've got a script that used to work and now times out, or a job you know will be too big for a single run, I fix and rebuild these under Apps Script development.
Questions people ask
How long can a Google Apps Script run?
Six minutes per execution for both free Gmail and Google Workspace accounts. Custom functions used in a cell formula get 30 seconds.
Does a paid Workspace account remove the six-minute limit?
No. Workspace raises daily quotas like trigger runtime, email recipients and URL Fetch calls, but each single run is still capped at six minutes.
Why does my script work on a small sheet but fail on a big one?
Usually because it reads or writes one cell at a time. Each call to the sheet is slow, so a loop that's fine on 100 rows runs out of time on 10,000. Reading the whole range at once with getValues usually fixes it.
Where can I see Google's current limits?
On the Apps Script quotas page at developers.google.com/apps-script/guides/services/quotas. Google changes these numbers from time to time, so treat any list, including this one, as a snapshot.