Paste twenty status cells and watch your onEdit fan out twenty UrlFetch calls, twenty labeling passes, or twenty “send Slack” attempts. The sheet feels haunted. You don’t need a smarter edit handler first — you need a quiet window.
This guide is about debouncing edit-driven work with CacheService. It is not a deep dive into simple vs installable triggers (you still pick the right one for auth and services). It is also not LockService — locks serialize concurrency; debounce drops rapid re-entry.
TL;DR
- Put a CacheService token at the top of the edit path
- If the token exists → return (skip); else put TTL and proceed
- Key by action (e.g.
onEdit:StatusCol), not every A1 address - TTL of 2–10 seconds matches “human finished typing/pasting”
- Still use locks for long overlapping jobs; debounce alone won’t protect them
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.
What thrash looks like
Typical failure modes:
- User pastes a column → N
onEditevents in one second - Formula cascades or multi-cell edits fire more events than you expect
- Your handler does I/O (UrlFetch, Gmail, calendar) per event → quota burn and duplicate side effects
Filtering to “only column C” helps, but paste into column C still explodes. Debounce collapses that storm into one run (or one run per quiet window).
The debounceOrSkip pattern
/**
* @param {string} actionKey
* @param {number=} ttlSeconds default 5; CacheService caps at 21600
* @return {boolean} true = run this time; false = skip
*/
function debounceOrSkip(actionKey, ttlSeconds) {
ttlSeconds = ttlSeconds == null ? 5 : ttlSeconds;
var cache = CacheService.getScriptCache();
var token = 'debounce:' + actionKey;
if (cache.get(token)) return false;
cache.put(token, '1', Math.max(1, Math.min(ttlSeconds, 21600)));
return true;
}
First caller in the window wins; everyone else during TTL exits cheaply.
Wire it into onEdit
function onEdit(e) {
if (!e || !e.range) return;
var sheet = e.range.getSheet();
if (sheet.getName() !== 'Pipeline') return;
if (e.range.getColumn() !== 4) return; // Status column only
if (!debounceOrSkip('onEdit:Pipeline:Status', 3)) {
console.log('onEdit debounced');
return;
}
// Optional: read the whole edited block once after the quiet window intent
syncStatusForRange_(e.range);
}
function syncStatusForRange_(range) {
var values = range.getValues();
// … one batched pass instead of per-cell side effects …
}
Practical tips inside the handler:
- Filter first, debounce second — don’t spend a cache write on irrelevant sheets.
- Batch the work you do after winning the debounce (read the range once).
- Log skips during bring-up so you can see the quiet window working.
Debounce vs lock vs “just sleep”
| Tool | Job |
|---|---|
debounceOrSkip |
Drop rapid re-entry; best-effort quiet window |
tryLockOrSkip / LockService |
Serialize two overlapping long runs |
Utilities.sleep |
Blocks this execution; does not stop other edit events |
A common combo: debounce so paste storms don’t start twenty jobs; lock so two “won” windows a minute apart don’t corrupt shared state.
function onEditInstallable(e) {
if (!e || !e.range) return;
if (!debounceOrSkip('onEdit:heavySync', 5)) return;
var lock = LockService.getScriptLock();
if (!lock.tryLock(500)) return;
try {
heavySync_(e);
} finally {
lock.releaseLock();
}
}
Use an installable onEdit when you need UrlFetch, open other files, or run as the installing user. Simple onEdit stays limited — debounce doesn’t lift those restrictions.
Choosing TTL and actionKey
- TTL too short → thrash returns under fast paste
- TTL too long → legitimate second edits feel “ignored” until the window ends
- Start around 3 seconds for status columns; 5–8 for handlers that open other spreadsheets
Action keys:
- Good:
onEdit:Pipeline:Status,onEdit:Intake:Assign - Too fine:
onEdit:Pipeline:R12(every row gets its own window; paste still fans out) - Too coarse:
onEditalone (unrelated columns block each other)
When debounce is the wrong tool
Skip debounce when every edit must produce a side effect (audit log of each cell change, per-row claim numbers). In those cases, either:
- Accept the fan-out and make work idempotent keyed by row id, or
- Buffer edits into a sheet queue and process with a time-driven trigger + checkpoint
Debounce shines when the latest state matters more than the intermediate keystrokes — status sync, “notify assignee,” recalculate a summary block.
Document cache vs script cache
CacheService.getScriptCache() is shared by all users of that script project — usually what you want for “someone already kicked off Status sync.” getDocumentCache() scopes to the current document and can help when one library is bound into many spreadsheets and you must not let Sheet A’s paste quiet Sheet B. Start with script cache; switch only if you observe cross-talk.
Soft Co-Pilot note
NitroGAS can drop in debounceOrSkip; Co-Pilot helps park it above your existing column filters — you still choose TTL and whether the handler should be installable. Free extension; Co-Pilot optional.
Closing checklist
- Column/sheet filters before any I/O
-
debounceOrSkipwith a stable action key - TTL tuned to how people edit that sheet
- Lock if the work can overlap across windows
- Installable trigger only when services require it
Happy Coding!


