Skip to content
// Apps Script

Prevent concurrent runs with LockService in Apps Script.

Two form submissions landing a second apart will race through your Apps Script trigger and silently corrupt any counter or sequential ID. LockService.getScriptLock() serializes those executions safely.

I need to stop two simultaneous form submissions from running my Apps Script trigger at the same time and corrupting my row counter or sequential invoice ID.

The script

copy · paste · trigger
assignSequentialId.gs
Apps Script
// Assigns a sequential invoice ID; safe under concurrent form submissions
function assignSequentialId(e) {
  var lock = LockService.getScriptLock();
  var acquired = lock.tryLock(10000); // wait up to 10 s

  if (!acquired) {
    throw new Error('Could not acquire lock after 10 s — skipping row');
  }

  try {
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Responses');
    var lastRow = sheet.getLastRow();
    var prevId = lastRow > 1 ? Number(sheet.getRange(lastRow - 1, 1).getValue()) : 0;
    var nextId = prevId + 1;
    sheet.getRange(lastRow, 1).setValue(nextId);
  } finally {
    lock.releaseLock();
  }
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

The silent corruption problem

When a Google Form gets two submissions within the same second, Apps Script can fire two separate trigger executions in parallel. Each one calls getLastRow() before the other has written its new row. Both read the same row number, both compute the same next ID, and you end up with two rows sharing one invoice number — with no error, no log entry, nothing. The first time I hit this, the sheet looked fine until accounting noticed duplicate invoice 1047.

Sequential IDs, running totals, and 'get the current max then write max+1' patterns are all vulnerable to this. Any read-modify-write sequence across two executions is a race condition waiting to happen.

waitLock vs tryLock, and why the timeout matters

LockService gives you two acquisition methods. waitLock(timeoutMs) blocks until the lock is free or throws a LockTimeoutException if it waits longer than the timeout. tryLock(timeoutMs) does the same but returns a boolean instead of throwing, so you decide what to skip or retry.

The snippet above uses tryLock with a 10,000 ms (10 second) timeout, which covers the realistic worst case where the first execution is doing a slow Sheets write or an external fetch. Apps Script triggers time out at 6 minutes, so a 10-second wait is cheap. For a lightweight counter update, 3,000 ms is enough. For a trigger that calls an external API before releasing, budget more.

The critical rule: always call releaseLock() inside a finally block. If your code throws after acquiring the lock and you skip the release, every subsequent execution will block for the full timeout period before failing. A forgotten releaseLock() turns a one-second outage into a ten-second outage on every submission.

Script lock vs document lock: pick the right scope

LockService.getScriptLock() serializes all executions of your script project, across all users and all spreadsheets that script is bound to. That is the right choice for a form-submission trigger writing to one sheet.

LockService.getDocumentLock() serializes per-document. Use it when the same script is bound to multiple documents and you want parallel executions across documents but serialized writes within each one. The third option, LockService.getUserLock(), serializes per-user — nearly useless for form triggers because simultaneous anonymous submissions count as different users and the lock will not block them.

A common mistake is reaching for getUserLock because it sounds 'safer.' It is not. For shared-resource writes, getScriptLock is almost always the right call.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
What happens if tryLock returns false — does the form submission get lost?
The row was already appended by the form trigger before your function ran; the data is there. What you skip is your post-processing logic (assigning the ID, sending the email, etc.). Whether that is acceptable depends on your use case. For IDs, the safer choice is to throw an error: Apps Script will log the failure and you can reprocess the row manually rather than leaving it silently unprocessed.
Can I use LockService inside a time-driven trigger, or only form triggers?
Any trigger type works. LockService is not tied to trigger type; it serializes by script execution context. Time-driven triggers can overlap if they are set to run every minute and an execution takes longer than 60 seconds, so the same tryLock pattern applies there.
Is 10 seconds a safe timeout for getLastRow plus setValue?
For a single Sheets read and a single write with no external calls, the operation completes in under 500 ms in practice. 3,000 ms covers it with room. 10,000 ms is generous but not wasteful — a blocked execution just waits in memory. The risk of setting it too low is a false timeout failure; the risk of too high is slower degradation under sustained load.
Does releaseLock() get called automatically when the execution ends?
Yes, Apps Script releases all held locks when an execution terminates, including on uncaught exceptions. The explicit releaseLock() in finally is still good practice because it frees the lock as soon as your critical section finishes, rather than holding it for the full remaining execution time — which matters when your function does more work after the write.
// one good script a week

Get a working Apps Script snippet in your inbox, weekly.