Re-running "Install triggers" should be safe. In too many client workbooks it isn't — every Setup click stacks another time-driven job, and suddenly the nightly sync fires five times, burns quota, and double-writes rows. Here's the idempotent pattern: delete by handler, create once, verify the project trigger list.
TL;DR
- Treat Setup as idempotent: same menu item, same end state, every time.
- Delete existing project triggers for that handler before you create.
- Create one time-driven (or installable) trigger with explicit options.
- Verify with
ScriptApp.getProjectTriggers()and toast the count. - Never create triggers from a function that already is the scheduled handler.
Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.
Why do duplicate triggers happen?
ScriptApp.newTrigger(...).create() always adds. It does not replace. If your onOpen menu calls installTriggers() and that function only creates, every reopen-and-click (or every deploy script that "helps") leaves another clone. Apps Script will happily run all of them in overlapping windows.
Symptoms look like:
- The same email / Slack ping N times
- Upserts that still look like appends because two runs race
- Quota errors that "started for no reason" after a quiet Setup click
- Log sheets with twin timestamps a few minutes apart
The root cause is almost never "Apps Script is buggy." It's Setup that describes actions ("create a trigger") instead of desired state ("exactly one trigger for this handler").
What does an idempotent install look like?
One function owns the contract: for handler runNightlySync, there should be exactly one project trigger (or zero, if you intentionally uninstall).
function deleteTriggersByHandler(handlerName) {
var triggers = ScriptApp.getProjectTriggers();
var removed = 0;
for (var i = 0; i < triggers.length; i++) {
if (triggers[i].getHandlerFunction() === handlerName) {
ScriptApp.deleteTrigger(triggers[i]);
removed++;
}
}
return removed;
}
function createDailyTrigger(handlerName, hour) {
hour = (hour == null) ? 6 : hour;
ScriptApp.newTrigger(handlerName)
.timeBased()
.everyDays(1)
.atHour(hour)
.create();
}
function installNightlySync() {
var handler = 'runNightlySync';
var removed = deleteTriggersByHandler(handler);
createDailyTrigger(handler, 2);
var left = ScriptApp.getProjectTriggers().filter(function (t) {
return t.getHandlerFunction() === handler;
}).length;
SpreadsheetApp.getActiveSpreadsheet().toast(
'Removed ' + removed + ', now ' + left + ' trigger(s) for ' + handler,
'Triggers',
8
);
}
function uninstallNightlySync() {
var n = deleteTriggersByHandler('runNightlySync');
SpreadsheetApp.getActiveSpreadsheet().toast('Removed ' + n, 'Triggers', 5);
}
Wire install/uninstall to a custom menu — not to onOpen auto-create, and not to the sync handler itself.
How do you verify after install?
Don't trust the toast alone on a messy project. List handlers:
function listProjectTriggers() {
var rows = ScriptApp.getProjectTriggers().map(function (t) {
return [
t.getHandlerFunction(),
String(t.getEventType()),
t.getUniqueId()
];
});
Logger.log(JSON.stringify(rows, null, 2));
return rows;
}
Open Triggers in the Apps Script UI too. If you see five copies of runNightlySync, run install once with the delete-first pattern and confirm you're down to one. Save a screenshot for the client handoff if they've been burned before — trust rebuilds faster with evidence.
Should Setup live in onOpen?
Usually no. onOpen simple triggers are for menus and lightweight UI. Creating installable triggers from onOpen surprises people (and can fail authorization in ways that look flaky). Prefer:
onOpen→ builds a Bootstrap menu- Menu item Install nightly sync →
installNightlySync - Menu item Uninstall nightly sync →
uninstallNightlySync
That makes Setup an intentional click with a clear toast. Power users can still re-run Setup after a copy of the workbook; they won't spawn doppelgängers.
How do you stop overlapping runs even with one trigger?
One trigger still overlaps a long run + a manual "Run now". Pair install hygiene with locks:
function runNightlySync() {
withScriptLock(function () {
// watermarked sync body — upsert, don't blind-append
}, 30000);
}
Or withDocumentLock for container-bound sheet mutations. Locks don't replace idempotent install — they cover the race you can't delete away. If tryLock fails, fail loud (toast + Log row) instead of skipping silently; silent skips look like "the sync ran" when it didn't.
What about editor vs installable triggers?
| Kind | Creates how | Gotcha |
|---|---|---|
Simple (onOpen, onEdit) |
Reserved names | Limited auth; UrlFetch/other services restricted |
| Installable (time-driven, onEdit installable) | ScriptApp.newTrigger |
Stacks if you don't delete first |
Clock triggers are almost always installable. Treat them like infrastructure: declare desired state, converge to it. If you need both a daily sync and a weekly digest, use two handler names — don't overload one function with mode flags that make Trigger UI archaeology harder.
How do you handle workbook copies?
Clients duplicate the spreadsheet. Triggers do not always come along the way people expect, and a "helpful" Setup in the template that auto-creates on first open can leave the original and the copy fighting the same external API. Document in the handoff:
- After File → Make a copy, run Install nightly sync once
- Confirm Triggers UI shows one handler
- Point API credentials / Script Properties at the copy's config
Idempotent install makes that checklist boring — which is the goal.
Minimal test plan
- Install once → Triggers UI shows one
runNightlySync. - Install again → still one; toast shows removed ≥ 1 then left = 1.
- Uninstall → zero.
- Manual run + scheduled window → lock prevents double mutation (spot-check Log sheet).
- Make a copy of the workbook → install once on the copy → still one trigger there.
What timezone does atHour use?
atHour follows the script project's timezone (File → Project settings), not the viewer's laptop and not necessarily the spreadsheet's display timezone. Set the project timezone explicitly on handoff, and document "runs around 2:00 America/New_York" (or whatever you chose). Approximate windows are normal — don't schedule a second trigger "just to be sure"; fix timezone instead.
If you need a tighter window, use .everyMinutes sparingly and still delete-by-handler first. More frequent triggers amplify doppelgänger damage.
Soft Co-Pilot note
If you're repeating this Setup dance across client projects, NitroGAS ships deleteTriggersByHandler, createDailyTrigger, and the lock helpers as snippets — Co-Pilot helps rename handlers to match the client's menu labels. Free extension; Co-Pilot optional. The install pattern above is complete either way.
Closing checklist
- Setup deletes by handler before create
- Menu-driven install/uninstall (not silent
onOpencreates) - Toast or log shows final trigger count
- Sync handler uses a lock + watermark/upsert so one run is safe
- Triggers UI spot-checked after first deploy
- Copy-workbook handoff includes one Install click
Happy Coding!


