Google Apps Script Triggers Tutorial: Automate Sheets, Docs, and Forms on a Schedule

Google Apps Script Triggers Tutorial: Automate Sheets, Docs, and Forms on a Schedule
function setupTriggers() {
  // Clean slate
  deleteAllTriggers();
  
  // Now create fresh
  ScriptApp.newTrigger("processData")
    .timeBased()
    .everyHours(1)
    .create();
}

Trigger Quotas and Limits You Need to Know

Google caps what you can do with triggers. Ignore these limits and your automations will break in confusing ways.

The hard numbers:

  • 20 triggers per user per script. Not per project. Per user. If you've created 20 triggers across one script, that's your ceiling.
  • 90 minutes total trigger execution time per day on consumer (free) Gmail accounts. Google Workspace accounts get 6 hours per day.
  • 6 minutes per execution. Every individual trigger run times out at 6 minutes regardless of your account type.
  • 30 seconds for simple triggers. Much shorter leash than installable ones.

That 90-minute daily cap is the one that bites most people. If you have a trigger running every minute and each execution takes 30 seconds, that's 30 minutes of your daily budget gone in an hour. With a free account, you'll burn through your quota before lunch.

Strategies to stay under limits:

  • Batch operations instead of processing one item per trigger run
  • Use conditional checks early in your function to bail out fast when there's nothing to do
  • Move heavy processing to a Cloud Function or external service and just use Apps Script for the trigger + handoff
  • If you need more than 90 minutes daily, upgrade to Google Workspace (6 hours is usually plenty)

One thing that trips people up: the quota resets 24 hours after your first execution of the day, not at midnight. So the reset time can drift if your usage pattern shifts.

Quick math to stay safe: if your function takes 30 seconds and runs every 5 minutes, that's 144 minutes per day. Already over the free limit. Either speed up the function, increase the interval, or move to Workspace.

Debugging Triggers When They Silently Fail

This is the part that makes triggers frustrating. When a time-driven trigger fails, you don't see an error. You're not staring at the screen when it runs. The function just doesn't do what it's supposed to, and you might not notice for days.

Step 1: Check the Executions log.

In the Apps Script editor, click "Executions" in the left sidebar. Every trigger execution shows up here with its status (Completed, Failed, Timed out) and any error messages. This is your first stop.

Step 2: Set up failure notifications.

When you create a trigger through the UI, there's a "Failure notification settings" dropdown. Set it to "Notify me immediately." Google sends an email from noreply-apps-scripts-notifications@google.com whenever your trigger throws an unhandled error. Don't leave this on the default weekly digest. By the time you get a weekly summary, you've lost a week of data.

Step 3: Wrap everything in try/catch with logging.

function processOrders() {
  try {
    const sheet = SpreadsheetApp.openById("SHEET_ID")
      .getSheetByName("Orders");
    const data = sheet.getDataRange().getValues();
    
    data.forEach((row, index) => {
      if (row[4] === "pending") {
        // process order
        Logger.log("Processed order on row " + (index + 1));
      }
    });
    
    Logger.log("Completed at " + new Date());
  } catch (error) {
    Logger.log("ERROR: " + error.message);
    Logger.log("Stack: " + error.stack);
    // Optionally email yourself
    GmailApp.sendEmail(
      "you@example.com",
      "Trigger Error: processOrders",
      "Error: " + error.message + "\nStack: " + error.stack
    );
  }
}

Step 4: Check for authorization issues.

Triggers run under the account that created them. If you change the scopes your script needs (by adding a new Google service), existing triggers might lose authorization. The fix: delete the trigger, re-authorize manually, then recreate the trigger.

Step 5: Use LockService for concurrent execution.

If a time-driven trigger fires while the previous execution is still running, you get race conditions. Apps Script doesn't queue by default. Use LockService to prevent overlap:

function safeProcessData() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(10000)) {
    Logger.log("Could not acquire lock, skipping this run");
    return;
  }
  
  try {
    processData();
  } finally {
    lock.releaseLock();
  }
}

Common silent failure causes:

  • The spreadsheet or form was deleted or moved to trash
  • Someone changed the sharing permissions on a resource
  • You hit your daily quota and the trigger was throttled
  • The script references a sheet tab by name, and someone renamed the tab
  • An API you're calling (UrlFetch) is returning errors that your code doesn't handle
๐Ÿ’ก
If your trigger worked yesterday and doesn't today, check Executions first. Nine times out of ten, the answer is right there.

5 Practical Google Apps Script Triggers Tutorial Recipes

Here are real trigger setups I've used in production. Each one is self-contained.

Recipe 1: Daily backup of a Google Sheet to Drive

function backupSheet() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const folder = DriveApp.getFolderById("YOUR_BACKUP_FOLDER_ID");
  const date = Utilities.formatDate(new Date(), "America/New_York", "yyyy-MM-dd");
  const copy = ss.copy(ss.getName() + " - Backup " + date);
  DriveApp.getFileById(copy.getId()).moveTo(folder);
}

// Run once to create the trigger
function setupBackup() {
  ScriptApp.newTrigger("backupSheet")
    .timeBased()
    .atHour(2)
    .everyDays(1)
    .create();
}

Recipe 2: Automate Google Sheets on a schedule with email digest

function dailySalesDigest() {
  const sheet = SpreadsheetApp.openById("SHEET_ID")
    .getSheetByName("Sales");
  const data = sheet.getDataRange().getValues();
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  
  let totalSales = 0;
  let orderCount = 0;
  
  data.forEach(row => {
    const orderDate = new Date(row[0]);
    orderDate.setHours(0, 0, 0, 0);
    if (orderDate.getTime() === today.getTime()) {
      totalSales += row[3];
      orderCount++;
    }
  });
  
  GmailApp.sendEmail(
    "team@example.com",
    "Daily Sales: $" + totalSales.toFixed(2),
    orderCount + " orders totaling $" + totalSales.toFixed(2) +
    "\nGenerated: " + new Date().toLocaleString()
  );
}

Recipe 3: Slack notification on form submit

function notifySlack(e) {
  const responses = e.response.getItemResponses();
  const payload = {
    text: "New form submission from " + responses[0].getResponse()
  };
  
  UrlFetchApp.fetch("YOUR_SLACK_WEBHOOK_URL", {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(payload)
  });
}

Recipe 4: Auto-archive old rows

function archiveOldRows() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const source = ss.getSheetByName("Active");
  const archive = ss.getSheetByName("Archive");
  const data = source.getDataRange().getValues();
  const cutoff = new Date();
  cutoff.setDate(cutoff.getDate() - 30);
  
  const rowsToArchive = [];
  for (let i = data.length - 1; i >= 1; i--) {
    if (data[i][0] instanceof Date && data[i][0] < cutoff) {
      rowsToArchive.push(data[i]);
      source.deleteRow(i + 1);
    }
  }
  
  if (rowsToArchive.length > 0) {
    archive.getRange(
      archive.getLastRow() + 1, 1,
      rowsToArchive.length, rowsToArchive[0].length
    ).setValues(rowsToArchive.reverse());
  }
}

Recipe 5: Stale task alerter

function checkStaleTasks() {
  const sheet = SpreadsheetApp.openById("SHEET_ID")
    .getSheetByName("Tasks");
  const data = sheet.getDataRange().getValues();
  const now = new Date();
  const stale = [];
  
  data.slice(1).forEach((row, i) => {
    if (row[3] !== "Done") {
      const lastUpdated = new Date(row[4]);
      const daysSince = (now - lastUpdated) / (1000 * 60 * 60 * 24);
      if (daysSince > 7) {
        stale.push({
          task: row[0],
          owner: row[1],
          days: Math.floor(daysSince),
          row: i + 2
        });
      }
    }
  });
  
  if (stale.length > 0) {
    let message = "These tasks haven't been updated in over a week:\n\n";
    stale.forEach(t => {
      message += "- " + t.task + " (assigned to " + t.owner 
        + ", " + t.days + " days stale)\n";
    });
    
    GmailApp.sendEmail("pm@example.com", 
      stale.length + " Stale Tasks Need Attention", message);
  }
}

Google Apps Script triggers are one of those things that seem simple on the surface but have enough quirks to trip you up. The gap between simple and installable triggers alone is responsible for half the "my script stopped working" posts I see. And the silent failure problem makes debugging feel like guessing.

But once you get the mental model right (simple for quick, no-auth reactions, installable for everything else) and you've built in proper logging and error handling, triggers become the backbone of some seriously useful automations. I've built systems that handle invoice processing, lead routing, reporting, and data cleanup, all running on nothing but Apps Script triggers and a few spreadsheets.

Start with the recipes above, adapt them to your use case, and check out our guides on automating Gmail with Apps Script and automating invoice processing for more hands-on examples.

โ† Back to Blog