Schedule reliable recurring jobs
Design Northwind time-driven automations that don't drift, double-run, or miss.
Published Oct 15, 2025
Time-driven triggers are the backbone of most automations — they fetch data, send reports, and tidy up without anyone clicking a button. They are also less precise than they look. A trigger set to “every hour” is a request, not a promise, and the gap between what you asked for and what actually happens is where reliable scheduling has to do its work.
The fix is not to fight the scheduler but to design jobs that tolerate its imprecision. A well-built recurring job produces the right result whether it runs early, late, twice, or once — so the scheduler’s quirks stop mattering.
Apps Script schedules drift
Apps Script does not guarantee an exact run time. It guarantees a window, and the actual moment within that window varies.
- “Every hour” means once somewhere inside each hour — not on the hour.
- Heavy load or quota pressure can push a trigger later than its window.
- “Every week, Monday 8am” can fire anywhere from roughly 7:55 to 8:30.
- A trigger can occasionally be skipped entirely, or fire twice close together.
This is fine for most jobs and fatal for a few. If your script assumes it runs at exactly 8:00, or assumes it runs exactly once, drift will eventually break it. Design for the window, not the instant.
Make it resilient
Two design choices absorb almost all scheduler imprecision. Together they make a job that does not care whether it ran early, late, or twice.
- Use idempotency. Build the job so two runs produce the same result as one — then a double-fire is harmless. See Design idempotent automations.
- Check elapsed time. Record the last successful run, and skip if too little time has passed. This deduplicates runs that fire too close together.
function hourlyJob() {
const props = PropertiesService.getScriptProperties();
// Read when the job last completed successfully (0 on the first ever run).
const last = parseInt(props.getProperty('lastRun') || '0');
// If the previous run finished under 30 minutes ago, this is a
// duplicate or early fire — skip it rather than doing the work twice.
if (Date.now() - last < 30 * 60_000) return;
doWork();
// Record completion only after the work succeeded, so a failed run
// does not block the next attempt from retrying.
props.setProperty('lastRun', String(Date.now()));
}
The order matters: write lastRun after the work, not before. If doWork
throws, lastRun stays unchanged and the next trigger is free to retry instead
of being skipped by the elapsed-time guard.
Self-monitoring
A silent recurring job is a liability — when it stops, no one notices until the missing output is missed. Build the job to report on itself.
- Log every run. Append a timestamped line to a Sheet on each successful run. The log doubles as a history and as proof the job is alive.
- Alert on silence. Have a separate check confirm a fresh log entry exists within the expected interval, and send an email if one is overdue.
- Allow a margin. Because schedules drift, alert only after
N+1intervals with no run — not the first time a run is a few minutes late.
The pattern is a watchdog: the job records that it ran, and a second, simpler job watches for the absence of that record. A missing entry is a far more reliable failure signal than waiting for someone to spot missing output.
Common mistakes
- Assuming an exact run time. Triggers fire within a window, not on the minute — never build logic that depends on the precise clock time.
- Assuming exactly one run. Triggers can double-fire; without an idempotency or elapsed-time guard, that means duplicate emails, rows, or charges.
- Writing
lastRunbefore the work. A run that fails after stamping the timestamp blocks the next attempt — record completion only on success. - No monitoring at all. A job that fails quietly can be broken for weeks; always log runs and alert on their absence.
- Alerting on the first late run. Drift is normal — a too-twitchy watchdog trains people to ignore it, so allow at least one extra interval of slack.