A menu item that archives rows or sends emails is dangerous when someone double-clicks it. withMenuCooldown(key, seconds, fn, opts) runs fn only if this menu action has not run in the last N seconds. Otherwise it shows a toast and returns null.
When to use
- Destructive or expensive custom menu actions
- Anything people tend to click twice while waiting
- Pairing with a confirm step for archive or delete flows
How to use this snippet
/**
* Run fn unless this menu key ran within the last `seconds`.
* Uses DocumentProperties for the timestamp and a document lock
* so two people (or a double-click) cannot both pass the check.
* Returns null when blocked by cooldown or a busy lock; otherwise returns fn().
* Errors from fn propagate after the lock is released.
*
* @param {string} key stable id for this menu action
* @param {number} seconds cooldown length
* @param {function(): *} fn
* @param {{title?: string, lockMs?: number}} [opts]
* @return {*}
*/
function withMenuCooldown(key, seconds, fn, opts) {
opts = opts || {};
var title = opts.title || 'Menu';
var ss = SpreadsheetApp.getActiveSpreadsheet();
var lock = LockService.getDocumentLock();
if (!lock.tryLock(opts.lockMs || 5000)) {
if (ss) ss.toast('Please wait — another action is running.', title, 5);
return null;
}
try {
var props = PropertiesService.getDocumentProperties();
var propKey = 'menuCooldown:' + key;
var now = Date.now();
var last = Number(props.getProperty(propKey) || 0);
var waitMs = Math.max(0, Number(seconds) || 0) * 1000;
if (waitMs && now - last < waitMs) {
var left = Math.ceil((waitMs - (now - last)) / 1000);
if (ss) ss.toast('Wait ' + left + 's before running again.', title, 5);
return null;
}
props.setProperty(propKey, String(now));
return fn();
} finally {
lock.releaseLock();
}
}
Example
function onOpen() {
SpreadsheetApp.getActive().addMenu('Ops', [{ name: 'Archive old rows', functionName: 'menuArchiveOld' }]);
}
function menuArchiveOld() {
withMenuCooldown('archiveOld', 30, function () {
// ... archive work ...
SpreadsheetApp.getActive().toast('Done', 'Archive', 5);
}, { title: 'Archive' });
}
Notes
- Document scope: the cooldown timestamp lives in
PropertiesService.getDocumentProperties(), so everyone using this spreadsheet shares it. That is usually what you want for a menu. Script properties would only throttle one user's script install. - Locking:
LockService.getDocumentLock()wraps the read-check-write so two concurrent clicks cannot both see "clear" and both run. If the lock is busy, you get a toast andnull, not a thrown error. - The cooldown timestamp is written before
fnruns. A failedfnstill burns the cooldown; that stops a crash-loop of retries from a menu mash. - This is not an undo. Pair destructive actions with an archive tab and a confirm step. See Confirm Dangerous Action.
Happy Coding!
