Prevent race conditions with LockService
Synchronise concurrent Northwind executions safely — single-writer invariants.
Published Jul 27, 2025
Apps Script feels single-threaded when you are writing it, but it is not. A project can have several executions running at the same moment — two triggers that overlap, a trigger that fires while a user clicks a menu item, or two people submitting the same form within a second of each other.
When two executions touch the same piece of shared state, the result depends on
the exact timing of their reads and writes. That is a race condition, and it
produces bugs that are intermittent, hard to reproduce, and easy to miss in
testing. LockService is the tool that makes shared state safe.
The problem
Consider a counter held in a Script Property. Each run reads the current value, adds one, and writes it back. With one execution that is fine. With two overlapping executions it is not.
- Run A reads
counter = 5. - Run B reads
counter = 5— before A has written anything. - Run A writes
6. - Run B writes
6— it never saw A’s update.
Two increments went in; the counter only moved by one. The lost update is silent — nothing throws, the numbers just quietly drift wrong. The same pattern corrupts any read-modify-write on shared state: appending to a list, claiming the next ID, marking a row as processed.
The fix
A lock forces the read-modify-write to happen as one indivisible unit. While one execution holds the lock, every other execution must wait its turn — so no run can ever read a value that another run is midway through changing.
function safeIncrement() {
const lock = LockService.getScriptLock();
// Wait up to 30 seconds for exclusive access. Throws if it cannot get it.
lock.waitLock(30_000);
try {
// Everything in here is now protected — no other run can interleave.
const props = PropertiesService.getScriptProperties();
const n = parseInt(props.getProperty('counter') || '0');
props.setProperty('counter', String(n + 1));
} finally {
// Always release, even if the body threw — otherwise the lock
// stays held until it expires and every later run stalls.
lock.releaseLock();
}
}
Two rules make this reliable. Keep the protected section as short as possible —
only the read and write that must be atomic. And always release in a finally
block, so a thrown error cannot leave the lock stuck.
Three lock scopes
LockService offers three lock scopes. Each guards a different boundary, and
the right choice is the narrowest one that still protects your invariant.
| Lock | Scope | Use when |
|---|---|---|
getScriptLock() | One per script project | Shared state lives in Script Properties or an external resource |
getDocumentLock() | One per bound document | The invariant is one specific spreadsheet or doc |
getUserLock() | One per user | Each user has their own state that only collides with themselves |
A narrow lock lets unrelated work run in parallel. A getUserLock() only blocks
the same user’s overlapping runs — two different users proceed freely. A
getScriptLock() serialises everything, which is correct for a global counter
but needlessly slow if the state was per-user all along.
Watch out for
waitLock(N)throws if it cannot acquire the lock withinNmilliseconds. Wrap the call intry/catchand decide whether to retry, skip, or log — never let it crash the run unhandled.- Forgetting to release. Without a
finallyblock, a thrown error leaves the lock held until it expires, stalling every execution behind it. - Holding a lock across
UrlFetchApp.fetch. A slow network call inside the protected section blocks every other invocation for its whole duration. Fetch first, then take the lock only for the quick write. - Locking the wrong scope. A
getScriptLock()where agetUserLock()would do serialises unrelated users and throttles the whole project. - Assuming a lock spans projects. Locks are per Apps Script project — they do not coordinate with a separate script touching the same Sheet.