appscript.dev
Guide Intermediate

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.

LockScopeUse when
getScriptLock()One per script projectShared state lives in Script Properties or an external resource
getDocumentLock()One per bound documentThe invariant is one specific spreadsheet or doc
getUserLock()One per userEach 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 within N milliseconds. Wrap the call in try/catch and decide whether to retry, skip, or log — never let it crash the run unhandled.
  • Forgetting to release. Without a finally block, 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 a getUserLock() 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.