"The auto-reply that worked yesterday had stopped — with no error at all." If you run several Google Workspace automations as an indie developer, you meet this silent failure sooner or later. In my own setup I was generating review-reply drafts for my wallpaper apps, summarizing inquiry emails, and rolling up sales figures — each on its own time-driven trigger. One day they all stopped together, and the execution log did not even record a failure.
The cause was not a bug in my code. I had exhausted Apps Script's trigger resources. We tend to design as if triggers are unlimited, but the ceiling arrives much earlier than expected. Below I pin that ceiling down in numbers, then show how to fold a sprawl of per-job triggers into a single dispatcher that fires every five minutes.
Triggers run dry against two separate limits
What most people miss is that there is more than one constraint. Exhaustion happens against two distinct resources.
The first is the number of triggers. Apps Script allows up to 20 triggers per user, per script. If you grow your automations as "one feature, one trigger," the 20th brings This script has too many triggers, and every ScriptApp.newTrigger after that fails quietly.
The second is the total trigger runtime per day. On a free gmail.com account, cumulative trigger runtime tops out around 90 minutes per day; even a paid Google Workspace account caps at roughly six hours. Because each Gemini API call spends seconds waiting on the network, that time budget melts faster than you would think as jobs pile up. Once it is gone, the rest of the day's triggers simply stop firing, with no notification.
The table below lists the key limits to keep in mind. Note that the numbers depend on the account type.
| Resource | Free (gmail.com) | Workspace (paid) | Symptom on exhaustion |
|---|---|---|---|
| Triggers / user / script | 20 | 20 | newTrigger fails |
| Total trigger runtime / day | ~90 min | ~6 hours | Firing stops silently |
| Max runtime per execution | 6 min | 30 min | Killed mid-run |
| UrlFetch calls / day | 20,000 | 100,000 | fetch throws |
The point that bites is this: a trigger-per-feature design presses on both limits at once. It consumes trigger slots, and the startup overhead and empty checks of each trigger eat into the runtime budget. When mine stopped, I was still under 20 triggers — but three triggers firing every five minutes had drained the daily runtime budget before noon.
Drop one-trigger-per-job and consolidate into a single dispatcher
The skeleton of the fix is simple. Create exactly one trigger and fire it every five minutes. That single entry point decides, from a schedule table, which jobs are due, and runs only those in order. The trigger count stays at one, and so does the runtime footprint.
Start with the job definitions. Each job carries only an interval (in minutes) and the function to run.
// Job definitions. Each is just a declaration: run fn once every intervalMin.
const JOBS = [
{ id: 'reviewReplyDraft', intervalMin: 15, fn: runReviewReplyDraft },
{ id: 'inquirySummary', intervalMin: 30, fn: runInquirySummary },
{ id: 'salesRollup', intervalMin: 60, fn: runSalesRollup },
];
// The single entry point called by the 5-minute trigger
function dispatch() {
const lock = LockService.getScriptLock();
// Avoid overlap if the previous run is still going. Bow out silently if we can't get it.
if (!lock.tryLock(1000)) return;
try {
const props = PropertiesService.getScriptProperties();
const now = Date.now();
const runStart = now;
const RUN_BUDGET_MS = 4 * 60 * 1000; // Safety valve: exit before the 6-min cap
for (const job of JOBS) {
if (Date.now() - runStart > RUN_BUDGET_MS) break; // Respect the time budget
const lastKey = 'last_' + job.id;
const last = Number(props.getProperty(lastKey) || 0);
if (now - last < job.intervalMin * 60 * 1000) continue; // Not due yet
try {
job.fn();
props.setProperty(lastKey, String(now)); // Advance only on success
} catch (e) {
console.error('job failed: ' + job.id + ' / ' + e);
// On failure, do not update last = it retries next cycle (each fn stays idempotent)
}
}
props.setProperty('heartbeat', String(now)); // Liveness record
} finally {
lock.releaseLock();
}
}Three things matter here. First, RUN_BUDGET_MS makes the loop exit before hitting the six-minute execution cap; any leftover jobs are picked up by the next dispatch five minutes later. Second, LockService prevents double execution — if a previous run overruns into the next firing, one of them quietly steps aside, so the same job never runs in parallel. Third, last_ is advanced only on success; on failure the timestamp does not move, so the job retries on the next cycle.