Google Apps Script (GAS) で重い処理(大量のメール送信、スクレイピング、API連携など)を実行したとき、誰もが一度はぶつかる壁。
「Exceeded maximum execution time(実行時間が制限を超えました)」

GASには「1回の実行は6分まで」という厳格なルールがあります。 これを回避するには、手動で都度再実行する必要があるのですが、正直めんどくさいですよね?
そこで今回は、「今あるコードのロジックは変えずに、たった4箇所追記するだけ」 で、自動的に続きから再開してくれる魔法のクラス(部品)を作りました。
【サクッと要約!】
・数千件のループ処理も、放置しておくだけで最後まで終わります。
・4分半経過すると自動で一時停止し、1分後に続きから再開します。
・難しい「トリガー設定」や「進捗保存」の処理を書く必要はありません。
ロジックの解説
ではロジックを解説します。
GASには同じ処理を連続して6分間以上続けて処理できない(タイムアウトする)という制約があります。
そのため処理を行う前にストップウィッチを走らせ、今が何秒経過したかをカウントしておき、それが360秒(=6分間の壁)に到達する前に、意図的に処理を中断させる。そして、中断させる際に、数分後、処理を再開させるトリガーを自動で設置する。というロジックを組むことによって、理論上6分の壁を超えることが可能なのです。
※ここでは、余裕を持った処理を実行するため、4分40秒経過したら中断するようにします。
時間の監視: 開始から「4分40秒」経過したかを常にチェックします。
進捗の保存: 時間が来たら、現在のループ番号(i)を「スクリプトプロパティ」という見えない保存場所にメモします。
未来の予約: 「1分後にまた私(関数)を実行して!」という時限トリガーをセットします。
再開: 次回実行時、プロパティを見て「ああ、前回は500番までやったな」と思い出し、501番からループを回します。
では、以下がコピペで使えるコードになります。
コピペ用コード
function myFunction() {
const looper = new ScriptLooper("myFunction"); // ★追加1 function名を一緒にする
const data = getData(); // ★追加2 単なるコピペ
for (let i = looper.getStartIndex(); i < data.length; i++) { // ★追加3 単なるコピペ
if (looper.shouldYield(i)) { // ★追加3 単なるコピペ
return; // ★追加3 単なるコピペ
} // ★追加3 単なるコピペ
// ===============================================
// ... ここに処理を完遂させたい内容をかく ...
// ... ここに処理を完遂させたい内容をかく ...
// ... ここに処理を完遂させたい内容をかく ...
// ===============================================
}
looper.finish(); // ★追加4 単なるコピペ
}
// ===============================================
// ここ以降のコードは何も変更する必要はありません
// ===============================================
class ScriptLooper {
constructor(functionName, timeLimitSec = 280) {
this.funcName = functionName;
this.limit = timeLimitSec * 1000;
this.startTime = new Date().getTime();
this.props = PropertiesService.getScriptProperties();
this.key = 'LOOP_INDEX_' + functionName;
// 開始時に古いトリガーを削除しておく
this._deleteTrigger();
}
// 続きから再開するための開始番号を取得
getStartIndex() {
return parseInt(this.props.getProperty(this.key)) || 0;
}
// 制限時間が迫っているかチェック (trueなら中断すべき)
shouldYield(currentIndex) {
const elapsed = new Date().getTime() - this.startTime;
if (elapsed > this.limit) {
// 現在のインデックスを保存
this.props.setProperty(this.key, currentIndex.toString());
// 1分後に自分自身をトリガー予約
ScriptApp.newTrigger(this.funcName).timeBased().after(60 * 1000).create();
console.log(`時間切れ回避: ${currentIndex}番目で中断し、次回予約しました。`);
return true; // 中断指示
}
return false; // 続行OK
}
// 処理完了後の後始末
finish() {
this.props.deleteProperty(this.key);
console.log("全処理完了。設定をリセットしました。");
}
_deleteTrigger() {
const triggers = ScriptApp.getProjectTriggers();
triggers.forEach(t => {
if (t.getHandlerFunction() === this.funcName) ScriptApp.deleteTrigger(t);
});
}
}具体的なコード例
今回は、6分を超える処理の例として「スプレッドシートの顧客リスト(1000件以上)に、1件ずつメールを一斉送信する。」という内容の処理を行うコードを用意しました。
【Before】修正前:必ずタイムアウトしてしまうコード
これは一般的な書き方ですが、データ量が多いと途中で「Exceeded maximum execution time」エラーが出て止まってしまいます。メール送信API(MailApp)の使用と、サーバー負荷軽減のための待機時間(Utilities.sleep)があるため、100件も処理しないうちに6分制限に引っかかってしまいます。
function sendMailBatch() {
// 1. シートとデータを取得
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("配信リスト");
const data = sheet.getDataRange().getValues(); // 全データ取得
// 2. ループ処理 (1行目はヘッダーなので、i=1 からスタート)
for (let i = 1; i < data.length; i++) {
const row = data[i];
const name = row[0]; // A列: 名前
const email = row[1]; // B列: メールアドレス
const status = row[2]; // C列: ステータス
// すでに送信済みならスキップ
if (status === "送信済み") {
continue;
}
try {
// 3. 重い処理 (メール送信)
MailApp.sendEmail({
to: email,
subject: "重要なお知らせ",
body: `${name} 様\n\nいつもご利用ありがとうございます。\n...`
});
// 4. 完了記録
// 行番号は i+1 になることに注意
sheet.getRange(i + 1, 3).setValue("送信済み");
console.log(`${name}様にメール送信完了`);
// 5. 待機 (これがあるため時間がかかる)
Utilities.sleep(2000); // 2秒待機
} catch (e) {
console.error(`送信エラー: ${email}`);
sheet.getRange(i + 1, 3).setValue("エラー");
}
}
}この処理を6分超えても実行されるようにするには、以下のように変更すれば良いのです!
【After】修正後:6分の壁を越えて完走するコード
修正前のコードに、「★」マークのついた4箇所を書き換え(追加)し、 一番下に「ScriptLooperクラス」を貼り付けます。
function sendMailBatch() {
const looper = new ScriptLooper("sendMailBatch"); // ★追加1 function名を一緒にする
const startIndex = looper.getStartIndex() || 1; // ★追加2 単なるコピペ
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("配信リスト");
const data = sheet.getDataRange().getValues();
for (let i = startIndex; i < data.length; i++) {
if (looper.shouldYield(i)) { // ★追加3 単なるコピペ
return; // ★追加3 単なるコピペ
} // ★追加3 単なるコピペ
// ===============================================
// ここから下は元のコードと全く同じです
// ===============================================
const row = data[i];
const name = row[0];
const email = row[1];
const status = row[2];
if (status === "送信済み") continue;
try {
MailApp.sendEmail({
to: email,
subject: "重要なお知らせ",
body: `${name} 様\n\nいつもご利用ありがとうございます。\n...`
});
sheet.getRange(i + 1, 3).setValue("送信済み");
console.log(`${name}様にメール送信完了`);
Utilities.sleep(2000);
} catch (e) {
console.error(`送信エラー: ${email}`);
sheet.getRange(i + 1, 3).setValue("エラー");
}
// ===============================================
// ここまで
// ===============================================
}
looper.finish(); // ★追加4 単なるコピペ
}
// ===============================================
// ここ以降のコードは何も変更する必要はありません
// ===============================================
class ScriptLooper {
/**
* @param {string} functionName - 実行中の関数名(トリガー再設定用)
* @param {number} timeLimitSec - 制限時間(秒)。デフォルト280秒(4分40秒)
*/
constructor(functionName, timeLimitSec = 280) {
this.funcName = functionName;
this.limit = timeLimitSec * 1000;
this.startTime = new Date().getTime();
this.props = PropertiesService.getScriptProperties();
this.key = 'LOOP_INDEX_' + functionName;
// 起動時に、自身の古いトリガーがあれば削除しておく(重複防止)
this._deleteTrigger();
}
/**
* 前回の続きから再開するためのインデックスを取得
* @return {number} 保存されたインデックス(なければ0)
*/
getStartIndex() {
return parseInt(this.props.getProperty(this.key)) || 0;
}
/**
* 制限時間が迫っているか判定し、次回予約を行う
* @param {number} currentIndex - 現在処理中のインデックス
* @return {boolean} trueなら中断すべき
*/
shouldYield(currentIndex) {
const elapsed = new Date().getTime() - this.startTime;
// 制限時間を超えていたら
if (elapsed > this.limit) {
// 1. 現在の場所を保存
this.props.setProperty(this.key, currentIndex.toString());
// 2. 1分後に再開するトリガーをセット
ScriptApp.newTrigger(this.funcName)
.timeBased()
.after(60 * 1000)
.create();
console.log(`時間切れ回避: ${currentIndex}番目で中断し、次回(1分後)を予約しました。`);
return true; // 中断指示
}
return false; // 続行OK
}
/**
* 全処理完了時の後始末
*/
finish() {
// 保存していた進捗を削除
this.props.deleteProperty(this.key);
console.log("全処理が完了しました。設定をリセットしました。");
}
// 内部用:トリガー削除
_deleteTrigger() {
const triggers = ScriptApp.getProjectTriggers();
triggers.forEach(t => {
if (t.getHandlerFunction() === this.funcName) {
ScriptApp.deleteTrigger(t);
}
});
}
}これでコーディングは完了です。



