Schedule Recurring Tasks with Google Apps Script Triggers
If you've ever wished a thing in Google Sheets, Gmail, or Drive would just happen on its own every Monday morning - or every hour, or at month-end - the answer is one of the most underused Apps Script features: time-based triggers. They're essentially a built-in cron service, free, that runs your code on Google's infrastructure. You don't need a server. You don't need a paid scheduler. You don't even need to keep a browser tab open.
This post is the practical guide to using them, the patterns that catch most people once they start scheduling things in volume, and the duplicate-trigger trap that produces extremely confused customer support emails about why the same message got sent eight times.
The two ways to create a trigger
The UI way (one-off, manual)
In the Apps Script editor, click the clock icon in the left sidebar. Click "Add Trigger." Choose a function, select "Time-driven," then pick the schedule. Done. The trigger is now persistent and runs even when no one has the editor open.
Good for: small projects, one or two triggers, things you'll set up once and never touch.
The code way (reproducible)
function createDailyTrigger() {
ScriptApp.newTrigger('sendDailyReport')
.timeBased()
.atHour(9)
.everyDays(1)
.create();
}
Run createDailyTrigger once from the editor. The trigger is created. Now sendDailyReport() will execute at roughly 9 am every day (Apps Script time triggers fire within a one-hour window of the scheduled time - it's not a precise alarm clock).
Good for: anything you might want to recreate on a different Sheet, share with a teammate, or modify later. Code-defined triggers can be version-controlled and re-applied; UI triggers cannot.
The schedule options, with examples
// Every hour
.timeBased().everyHours(1)
// Every 4 hours
.timeBased().everyHours(4)
// Daily at 9am
.timeBased().atHour(9).everyDays(1)
// Every Monday at 8am
.timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(8)
// 1st of every month at 9am
.timeBased().onMonthDay(1).atHour(9)
// At a specific date/time (one-off)
.timeBased().at(new Date(2026, 11, 25, 9, 0)) // Christmas 9am
Note: Apps Script time triggers run in the script's timezone. Set it explicitly with project properties or the manifest file - otherwise it defaults to the script owner's account timezone, which can produce surprises if the owner is in a different region than the users.
The duplicate-trigger trap
This is the bug we've seen catch almost every team that starts scripting at scale. The story always goes something like this:
You write createDailyTrigger(). You run it. It works. Three weeks later, you tweak the schedule - you decide 8 am is better than 9 am. You edit the function, run it again. Now you have two triggers. The next week, you decide you want it to send to a different recipient. Edit, run. Three triggers. By month two, you have eight triggers all firing the same function with slightly different intended behaviors. The first user complaint comes when they receive eight copies of the same daily report.
The fix is a deletion pattern, applied every time you re-create:
function ensureTrigger(fnName, scheduleFn) {
// Delete any existing triggers for this function
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction() === fnName) ScriptApp.deleteTrigger(t);
});
// Then create the new one
scheduleFn(ScriptApp.newTrigger(fnName)).create();
}
function setupDaily() {
ensureTrigger('sendDailyReport', t => t.timeBased().atHour(8).everyDays(1));
}
Now setupDaily() is idempotent. Run it 100 times, you still end up with exactly one trigger. This is one of those small patterns that pays for itself the first time you avoid the duplicate-trigger incident.
Reasonable failure handling for scheduled jobs
The big difference between an interactive function (you ran it, you see the error) and a scheduled one (it ran at 3am, you have no idea what happened) is observability. A few habits make the difference.
1. Wrap the body in try/catch and log to a sheet.
function sendDailyReport() {
const log = SpreadsheetApp.openById('LOG_SHEET_ID').getSheetByName('runs');
try {
// ... actual work ...
log.appendRow([new Date(), 'sendDailyReport', 'OK', '']);
} catch (err) {
log.appendRow([new Date(), 'sendDailyReport', 'ERROR', err.message]);
throw err; // re-throw so Apps Script also surfaces it
}
}
You now have a permanent record of every run, success or failure. Five minutes of investment, hours of debugging saved.
2. Enable failure notification emails. In the trigger UI (clock icon), each trigger has a "Failure notification settings" option. Set it to "Notify me immediately." Apps Script will email you whenever the trigger throws an uncaught exception. This is the cheapest monitoring you can buy.
3. Use a watchdog trigger. For critical schedules, add a second trigger that runs daily and checks: "Did the main job successfully complete in the last 24 hours?" If not, alert. The watchdog detects the case where the trigger itself silently stops firing - which can happen if a quota is exceeded.
Five recurring jobs worth setting up today
1. Weekly metrics digest. Pull numbers from your analytics or sheet, format them, email to the team. Saves the manual "let me update the weekly numbers" task forever.
2. Subscription renewal reminders. Read a Sheet of customer renewal dates, send each one a polite email 14 days before renewal. Catches the customers who would have churned silently.
3. Stale form responses cleanup. A monthly job that deletes Google Form responses older than your retention policy (12 months, say) - simple GDPR hygiene.
4. Backup of a critical Sheet to Drive. Make a copy of an important sheet with a date-stamped name every day. If someone corrupts the live sheet, you have yesterday's version. The whole thing is about 8 lines:
function dailyBackup() {
const original = DriveApp.getFileById('SHEET_ID');
const folder = DriveApp.getFolderById('BACKUP_FOLDER_ID');
const dateStr = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd');
original.makeCopy('Backup ' + dateStr + ' - ' + original.getName(), folder);
}
5. End-of-month report generation. Triggered on the 1st of each month, generates the previous month's summary PDF (per our PDF generation post), saves it to Drive, emails it to whoever needs it. No more "I need to remember to do the monthly report" task.
Limits and gotchas
Execution time. Each individual trigger run has a maximum of 6 minutes (or 30 minutes on Workspace accounts). If your scheduled job is long, you need to either break it into chunks that each fit, or use the PropertiesService to track progress across runs.
Total daily quota. Apps Script gives you about 90 minutes of total trigger execution per day on a free account, 6 hours on Workspace. Most schedules are well under this, but a misbehaving job that loops slowly can burn through it.
20 triggers per script, per user. A hard cap. If you find yourself approaching it, you almost certainly have either duplicate triggers (see above) or you're trying to manage too many distinct schedules in one script project - split into multiple projects.
Triggers stop if the script is deleted or unshared. The trigger lives in the script owner's account. If the owner leaves the company and the account is deactivated, every trigger that owner created stops firing - silently. For business-critical schedules, the script should be in a Shared Drive owned by the organization, not by an individual.
Why this is worth learning
Almost every "I need to remember to do X every week" task is a candidate for a trigger. Almost every "I'll get to it next week" automated reminder is a candidate. Almost every "let me run the report" Monday-morning ritual is a candidate. The cumulative time recovered, across a year of these, is significant - probably tens of hours per person at the team level.
And, more importantly, the things that get automated stop being things that depend on you remembering to do them. That's the real prize: not the recovered time, but the recovered attention.