Blog / Technical Guides

Apps Script 'Exceeded Maximum Execution Time': How to Fix It

By Keyona Meeks, Founder, ReRev Labs · Oct 31, 2026 · 10 min read

The short answer: Apps Script stops a single run after a fixed time. Google's quotas page lists "6 min / execution." The fix is almost always one of two things. First, make each run faster by reading and writing a whole range at once instead of one cell at a time. Second, if the job is still too long, split it: process rows until time runs low, save your place in the sheet, and schedule a follow-up run. This post has a tested script that does the second. I mock-tested it; I did not run it in a live Google account.

By the end of this post, you'll be able to:

  • Say which limit your script hit.
  • Speed up the usual culprit, a loop that touches the sheet one cell at a time.
  • Split a long job into runs that pick up where the last one stopped.
  • Test the split on a copy before you rely on it.

A script that worked on 200 rows now fails on 4,000. The log says it ran for a long time and then stopped. Nothing changed in the code. The data grew.

What does "Exceeded maximum execution time" mean?

It means a run lasted longer than Google allows. Google's quotas page, which I read on October 30, 2026 through a fetch tool that returned excerpts, lists "Script runtime" at "6 min / execution" for both consumer and Google Workspace accounts. Source: Quotas for Google Services, checked October 30, 2026.

I did not find the exact phrase "Exceeded maximum execution time" in the page text I got back. People report that message when a run hits the limit. If your message is different, such as one about a service quota, you may have hit a different limit, so read it before you change anything.

Which time limit did you hit?

Three limits get confused:

  • One run: 6 minutes. The "6 min / execution" row above.
  • All trigger runs in a day. The same page lists "Triggers total runtime" at "90 min / day" for consumer accounts and "6 hr / day" for Google Workspace accounts. A job that runs often can use up the day even if each run is short.
  • Simple triggers: 30 seconds. Google's simple triggers page says they "can't run for longer than 30 seconds." Source: Simple triggers, checked October 30, 2026. If your slow function is an onEdit or onOpen, the limit is much smaller. See what Google Apps Script is for background, or move the work to an installable trigger.

The numbers above are as of October 30, 2026. The same page says "All quotas are subject to elimination, reduction, or change at any time, without notice," so recheck before you rely on them.

Is your loop touching the sheet one cell at a time?

This is the usual cause. Google's best-practices page says "Alternating read and write commands is slow," and advises to "read all data into an array with one command." Source: Apps Script best practices, checked October 30, 2026. It says to do the work in the array and write the result back in one command.

Slow:

for (let r = 2; r <= lastRow; r++) {
  const v = sheet.getRange(r, 1).getValue();
  sheet.getRange(r, 2).setValue(v.trim());
}

Faster:

const input = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
const output = input.map(function (row) { return [String(row[0]).trim()]; });
sheet.getRange(2, 2, output.length, 1).setValues(output);

The first version makes two calls to the sheet per row. The second makes two calls in total. I did not time either one, so I do not quote a speedup. Try it on your data and compare.

What else makes a run slow?

  • A call to another service inside the loop. Sending one email, fetching one web page or creating one file per row all take real time. The batching fix does not help here, and splitting the job across runs does.
  • Reading the whole sheet more than once. Read once, keep it in a variable.
  • Looking up the same thing again and again. Google's best-practices page says to "use the Cache service to cache resources between script executions." Source: same page. Use it for values that do not change between runs.
  • A formula-heavy sheet. If the script writes to cells that feed many formulas, the sheet recalculates. I could not confirm how much that costs in a run, so test with the formulas off a copy.

When is batching not enough?

When each row needs a slow step, such as an email, a web request or a file. Then even a fast loop adds up. Do the job in pieces, and let the sheet remember what is done.

The pattern:

  1. Mark each row as done in a column, as you finish it.
  2. Each run takes the rows not marked done.
  3. When time runs low, stop and schedule another run.
  4. The next run skips the done rows and continues.

The sheet is the bookmark. You do not need to store a position anywhere else, and if the script crashes you lose only the rows since the last write.

Google's ClockTriggerBuilder page says after(durationMilliseconds) "specifies the minimum duration (in milliseconds) after the current time that the trigger runs," and that "the actual duration might vary, but won't be less than your specified minimum." Source: ClockTriggerBuilder, checked October 30, 2026. So the follow-up starts at least a minute later, not exactly then.

How do you set up the split script?

Your sheet needs three columns, spelled exactly like this: Name (the input), Clean name (the output) and Processed (the script fills it with the time each row is finished). The example step just trims spaces. Replace processRow_ with your own slow step.

  1. Open your sheet. Choose Extensions, then Apps Script. If this is new to you, read what Google Apps Script is first.
  2. Delete the placeholder code and paste the script below.
  3. Change the sheet name and the three column names to yours.
  4. Replace the inside of processRow_.
  5. Click Save.
  6. In the function list, choose processRows and click Run. Google asks you to allow the script to use your sheet. Read the list. Approve it only if you agree.
// Long job in batches: process rows until time runs low, then schedule a continuation.
// Paste into Extensions > Apps Script in the sheet that holds the rows to process.

const SHEET_NAME = 'Sheet1';
const INPUT_COL = 'Name';
const OUTPUT_COL = 'Clean name';
const DONE_COL = 'Processed';

const TIME_BUDGET_MS = 4.5 * 60 * 1000;
const CHUNK_SIZE = 50;
const CONTINUE_AFTER_MS = 60 * 1000;

// Replace this with your own slow step. It gets one input value and returns one output value.
function processRow_(input) {
  return String(input).trim().replace(/\s+/g, ' ');
}

function processRows() {
  const started = Date.now();
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(10000)) return;
  try {
    deleteContinuations_();
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
    const values = sheet.getDataRange().getValues();
    if (values.length < 2) return;
    const head = values[0].map(String);
    const inAt = head.indexOf(INPUT_COL);
    const outAt = head.indexOf(OUTPUT_COL);
    const doneAt = head.indexOf(DONE_COL);
    if (inAt < 0 || outAt < 0 || doneAt < 0) {
      throw new Error('Need the columns: ' + [INPUT_COL, OUTPUT_COL, DONE_COL].join(', '));
    }

    const out = values.slice(1).map(function (r) { return [r[outAt]]; });
    const done = values.slice(1).map(function (r) { return [r[doneAt]]; });
    const pending = [];
    for (let i = 0; i < done.length; i++) {
      if (String(done[i][0]).trim() === '' && String(values[i + 1][inAt]).trim() !== '') pending.push(i);
    }

    let p = 0;
    while (p < pending.length) {
      const stop = Math.min(p + CHUNK_SIZE, pending.length);
      for (; p < stop; p++) {
        const i = pending[p];
        out[i][0] = processRow_(values[i + 1][inAt]);
        done[i][0] = new Date();
        if (Date.now() - started > TIME_BUDGET_MS) { p++; break; }
      }
      sheet.getRange(2, outAt + 1, out.length, 1).setValues(out);
      sheet.getRange(2, doneAt + 1, done.length, 1).setValues(done);
      if (p < pending.length && Date.now() - started > TIME_BUDGET_MS) {
        ScriptApp.newTrigger('continueProcessing').timeBased().after(CONTINUE_AFTER_MS).create();
        return;
      }
    }
  } finally {
    lock.releaseLock();
  }
}

function continueProcessing() {
  processRows();
}

function deleteContinuations_() {
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === 'continueProcessing') ScriptApp.deleteTrigger(t);
  });
}

What does the script do?

  • Reads once. It loads the whole sheet in one call.
  • Works on rows not yet marked done. A row with an empty Processed cell and a filled input.
  • Writes in chunks. After every 50 rows it writes the output and Processed columns back in two calls.
  • Watches the clock. At 4.5 minutes it stops, and if rows remain it schedules continueProcessing for about a minute later. That leaves margin under the 6-minute limit.
  • Cleans up. Each run deletes its own earlier continuation trigger first, so they do not pile up. Google's quotas page lists "Triggers" at "20 / user / script," so a runaway loop of triggers would hit a limit.
  • Takes a lock. If two runs start at once, only one works.

It does not wait for you. Once the first run starts, the follow-ups happen by themselves until every row is done.

What if the slow step has side effects?

Read this before you point the script at anything that sends or creates something.

The script writes results after each chunk, not after each row. If a run is cut off in the middle of a chunk, the rows in that chunk were never marked done, and the next run does them again. For a trim, that is harmless. For an email, it means a duplicate.

If processRow_ sends an email, creates a file or calls a service that should happen once, set CHUNK_SIZE to 1 so each row is marked done right after it finishes. That makes more calls to the sheet, which is slower. It is the price of not repeating work. Better still, have the slow step record its own proof of completion, such as a "sent" timestamp, and check that before doing it again.

How do you test it before you rely on it?

Test on a copy, with fake rows.

  1. Make a copy of the sheet. Paste 150 fake names, some with extra spaces.
  2. Run processRows. Check that Clean name is filled and Processed has a time on every row.
  3. Make the job slow on purpose. Add Utilities.sleep(2000); inside processRow_, and lower TIME_BUDGET_MS to 20 * 1000 for the test. Run it. It should stop after about ten rows.
  4. Look at the project's triggers. You should see one continuation trigger. Wait for it to fire. More rows should fill in.
  5. Wait for the chain to finish. After the last row, no continuation trigger should remain.
  6. Clear a few Processed cells. Run again. Only those rows should change.
  7. Put the budget and the sleep back before you use it on real data.

What can go wrong?

  • A single step takes longer than 6 minutes. The script cannot split one row. Make that step shorter or run it somewhere else.
  • The follow-up never starts. Check the project's triggers. Check that the daily trigger runtime above is not used up.
  • Duplicates. See the side effects section. Set CHUNK_SIZE to 1.
  • It stops with "Need the columns." A header does not match. Check spelling, spaces and capital letters.
  • It runs forever. A row that never gets marked done will be picked up each run. Make sure processRow_ always returns, and that the input column is not blank for rows you want to skip.
  • You also have a regular scheduled trigger on processRows. That is fine. The cleanup only deletes triggers for continueProcessing.

Where does an LLM fit in this?

Not in the limit itself. The fix is plain code: batch the calls, mark progress, schedule the next run. An LLM can help you read a long script and point at the loop that calls the sheet one cell at a time, and it can draft the batched version. A person should run the result on a copy and compare the output to the old one. My stance: AI writes the code, plain automation runs it, and an LLM shows up only where the input is messy and a person checks the result. If you are not sure which jobs are worth fixing first, read what to automate first in a small business.

Where this has limits

  • Not run in Google. I tested the logic in a mock with a simulated clock and 19 checks. I have not run it in a live Google account.
  • Unconfirmed behavior. I did not confirm how the lock behaves with real overlapping runs, or how closely after fires to the time you set. The test steps cover what you can see.
  • I read Google's pages through a fetch tool that returned excerpts. Check the numbers against the live quotas page.
  • Quotas change. Everything here is as of October 30, 2026.
  • One run still has a ceiling. If one step takes longer than the limit, splitting rows will not help.
  • I do not time or benchmark anything. I make no claim about how much faster the batched version is on your data.

If a script keeps timing out and you would rather hand it over, see our AI-powered operations page or tell us about the job on /letstalk. Bring the sheet layout and a copy with fake data. Do not send passwords or login details.

Frequently asked questions

What is the Apps Script execution time limit?

Google's quotas page lists "6 min / execution" for a single script run, as of October 30, 2026. Simple triggers have a shorter limit, and there is a separate daily limit on total trigger runtime.

How do I fix "Exceeded maximum execution time"?

First batch your reads and writes so the script touches the sheet in a few calls instead of thousands. If it is still too long, split the job into chunks, mark finished rows and schedule a follow-up run.

Can I raise the limit?

I found nothing on the quotas page that lets you raise it. Check the page for your account type.

Why does my script take so long on a big sheet?

The usual cause is calling the sheet once per cell inside a loop. Read the whole range into an array, work on the array and write the result back at once.

Will the follow-up run start right away?

No. Google says the actual start time can vary but will not be less than the minimum you set.

Will a split job send duplicate emails?

It can if a run is cut off mid-chunk and the step sends an email. Set the chunk size to 1, or have the step record that it has already sent.

Read next: Run a script on a schedule and get told when it fails covers the trigger most likely to hit this limit and how to hear about it when it does.