Google Sheets Apps Script runs extremely slow and is not consistent with multiple users

I have a Google spreadsheet that is used concurrently by a team of people located in the Philippines, South America and the U.S. The load on the spreadsheet is usually no more than ten people at a time. The spreadsheet has two sheets, one being a “Submissions” page and the other is named “Archive”. There is existing data in the spreadsheet but I don’t believe it’s enough to cause these issues.

The logic is pretty simple. There is a dropdown in Column 14 that has the values “Assignment”, “Processing”, “Closed”, “Duplicate” and “Saved”. If the selection is “Assignment” or “Processing” there are timestamps captured at columns 10 and 11. If the selection is “Closed”, “Duplicate” or “Saved” there is a timestamp captured on row 12, it checks to make sure the timestamp is captured and the row is moved to the “Archived” tab.

I’m having issues with the script not being consistent capturing the timestamps or moving the row if there are more than one person using the dropdown. Sometimes it works perfectly and captures the timestamps as needed and moves the data as it should. It seems that always if there’s more than one person working in the spreadsheet it has issues.

The other issue is that it’s very slow. It could be because of some of my attempts to make it consistent with locks and retries.

The errors always seem to be either a “Exceeded max execution time” or “Exception: Lock timeout: another process was holding the lock for too long.”

I’ve rewritten this and tried different logic and I’m still having issues. My priority is that the script works consistently in capturing timestamps and moving the data, secondly is the speed of the script. My code is below, please overlook any logic that may be incorrect, kind of a newbie with Apps Script.

function onEdit(e) {
  if (!e || !e.range) {
    Logger.log('Event object is invalid or undefined.');
    return;
  }

  var lock = LockService.getScriptLock();
  var maxRetries = 5; // Increased number of retries for high contention
  var retryDelay = 10000; // 10 seconds delay between retries
  var attempt = 0;

  while (attempt < maxRetries) {
    try {
      // Attempt to acquire the lock.
      lock.waitLock(15000); // Wait for up to 15 seconds

      var range = e.range;
      var sheet = range.getSheet();
      var value = range.getValue();

      // Only proceed if the edit is in 'Submissions' and in column 14.
      if (sheet.getName() !== 'Submissions' || range.getColumn() !== 14) {
        return;
      }

      var timestampColumn = getTimestampColumn(value);
      if (timestampColumn === null) {
        return;
      }

      // Check if a timestamp is already present; if so, exit
      var existingTimestamp = sheet.getRange(range.getRow(), timestampColumn).getValue();
      if (existingTimestamp) {
        lock.releaseLock();
        return;
      }

      // Write the timestamp
      var timestamp = new Date();
      sheet.getRange(range.getRow(), timestampColumn).setValue(timestamp);

      // Move row to 'Archived' sheet if needed
      if (['Ticket closed', 'Unnecessary', 'Saved'].includes(value)) {
        moveRowToCompleted(sheet, range.getRow());
      }

      break; // Exit loop if successful

    } catch (e) {
      Logger.log('Error: ' + e.toString());
      attempt++;
      if (attempt < maxRetries) {
        Utilities.sleep(retryDelay); // Wait before retrying
      } else {
        Logger.log('Exceeded maximum retries. Giving up.');
      }
    } finally {
      lock.releaseLock(); // Ensure the lock is always released
    }
  }
}

function getTimestampColumn(value) {
  switch (value) {
    case 'Assignment':
      return 10;
    case 'Processing':
      return 11;
    case 'Closed':
    case 'Duplicate':
    case 'Saved':
      return 12;
    default:
      return null;
  }
}

function moveRowToCompleted(sheet, rowIndex) {
  var numColumns = sheet.getMaxColumns();
  var rowValues = sheet.getRange(rowIndex, 1, 1, numColumns).getValues();
  
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var targetSheet = ss.getSheetByName('Archived');
  var targetLastRow = targetSheet.getLastRow();
  targetSheet.getRange(targetLastRow + 1, 1, 1, numColumns).setValues(rowValues);
  
  // Delete the row after moving to avoid concurrent modification issues.
  sheet.deleteRow(rowIndex);
}

I tried with and without locks and retries. I tried with and without Google’s Api. I am currently trying to clear some data out of the spreadsheet and maybe try to take care of some functionality in the Sheet itself if possible.

Edit: I thought I had installed a trigger? I clicked the plus sign on the “Add Trigger” tab at the bottom right of the page. There was then a prompt with dropdowns for “On Edit”(which function), “Head”(what deployment), “From Spreadsheet”(event Source) and “In Open”(event type). When looking at the “Executions” tab, sometimes the exectutions say “Simple Trigger” others just “Trigger”. I may have done something wrong setting it up or trying to use it?

3

Function name

onEdit is a reserved name for the on-edit simple trigger. If you add an on-edit installable trigger and set onEdit as the trigger’s handler function, the onEdit function might be fired twice by the same edit event.

If you use an on-edit installable trigger, change the name of the onEdit function.

Script performance

In general, calls to Google Apps Script methods are slow, but the SpreadsheetApp.Sheet.deleterow and any method that changes the spreadsheet structure is very expensive regarding execution time. You might improve the script performance by using the Advanced Sheet Service, more specifically, the Sheets.Spreadsheets.batchUpdate method.

Tips:

  • Instead of using SpreadsheetApp.Range.getValue to get the value of the edited cell, use e.value. Please remember that this property will return undefined when the cell value is cleared.
  • In the same way, instead of using SpreadsheetApp.Range.getColumn, use e.range.columnStart. This property is not documented but has been available for a long time.

Errors

Exceeded maximum execution time

Simple triggers have a 30-second execution time limit. Installable triggers might have 6 or 30 minutes as the execution time limit, based on the type of account used. For details, see https://developers.google.com/apps-script/quotas

Note: At this time, the quotas page mentions 6 minutes for both types of accounts, but in practice, Workspace accounts, most of the time, have a 30-minute time limit.

Exception: Lock timeout: another process was holding the lock for too long.

This error is caused by the Lock Service script and the users’ behavior. The user’s behavior might be caused by the spreadsheet design.

You might have to rethink your spreadsheet design if you can’t change the user behavior and the script control flow.

Reference

  • Simple Triggers
  • Installable Triggers
  • Event Objects

2

As Wicket notes, you should use a simple trigger and delete any installable triggers you may have created, or rename onEdit(e) to something like installableOnEdit(e) and create exactly one installable trigger to run it. Simple triggers may only run for 30 seconds, while installable triggers may run up to ten times longer. But the root of the problem is probably not with trigger limited runtime — the problem is with the exceedingly long time you say it takes to get a simple timestamp in place and a row moved.

Chances are that the bad performance is caused by the spreadsheet rather than the script. To improve spreadsheet performance, see my optimization tips.

The code unnecessarily calls many SpreadsheetApp methods inside the loop, including Range.getSheet(), Range.getValue(), Sheet.getName(), Range.getColumn() and Range.getRow(). Replace these calls with references to the event object e and move as many API calls outside the loop as possible. See these onEdit(e) optimization tips.

Here’s a blueprint for a version that observes some best practices:

'use strict';

/**
* Simple trigger that runs each time the user manually edits the spreadsheet.
*
* @param {Object} e The onEdit() event object.
*/
function onEdit(e) {
  if (!e) throw new Error('Event object is invalid or undefined.');
  if (e.range.columnStart !== 14) return;
  const timestampColumn = getTimestampColumn(e.value);
  if (timestampColumn === null) return;
  const sheet = e.range.getSheet();
  if (sheet.getName() !== 'Submissions') return;
  const timestampCell = sheet.getRange(e.range.rowStart, timestampColumn);
  if (timestampCell.getValue()) return;
  timestampCell.setValue(new Date());

  const maxRetries = 5;
  let attempt = 0;
  while (attempt < maxRetries) {
    try {
      const lock = LockService.getScriptLock();
      lock.waitLock(5 * 1000);
      if (['Ticket closed', 'Unnecessary', 'Saved'].includes(e.value)) moveRowToCompleted(sheet, e.range.rowStart);
      return;
    } catch (error) {
      console.log(error);
      attempt += 1;
      if (attempt === maxRetries) {
        console.log('Exceeded maximum retries. Giving up.');
        return;
      }
    }
  }
}

Locks are automatically cleared when the script completes so the finally() section can be omitted here.

Whether locking is required in the first place seems to only depend on whether the moveRowToCompleted function is atomic. If it uses Sheet.appendRow() rather than Sheet.setValues(), and clears the source row rather than deletes it, chances are that it is atomic and you can do without locking. Using Range.clearContent() instead of Sheet.deleteRow() would probably improve performance in any case. Try this:

/**
* Copies a row from the current sheet to 'Archived' and
* clears the row in the current sheet.
*
* @param {SpreadsheetApp.Sheet} sheet The sheet where the row is.
* @param {Number} rowIndex The row number of the row.
*/
function moveRowToCompleted(sheet, rowIndex) {
  const range = sheet.getRange(rowIndex, 1, 1, sheet.getLastColumn());
  const values = range.getValues().flat();
  sheet.getParent().getSheetByName('Archived').appendRow(values);
  range.clearContent();
}

If that is acceptable, you can remove locking in onEdit(e).

5

I renamed the trigger installableOnEdit and reworked the code and at first it did seem to work beautifully, but just when moving one row of data at a time. I tested using six rows, didn’t quite make it to moving the data, just the selection for the first timestamp (Assignment). The script ran and populated the 4th record of the 6th. No other timestamps populated. I stopped there because of the failure and that will cause issues. This was the code I tested:

function installableonEdit(e) {
  if (!e || !e.range) {
    Logger.log('Event object is invalid or undefined.');
    return;
  }

  var range = e.range;
  var sheet = range.getSheet();
  var value = range.getValue();
  var startRow = range.getRow();
  var endRow = range.getLastRow();
  var column = range.getColumn();

  // Only proceed if the edit is in 'Submissions' and in column 14.
  if (sheet.getName() !== 'Submissions' || column !== 14) {
    return;
  }

  // Loop through all affected rows
  for (var row = startRow; row <= endRow; row++) {
    var currentValue = sheet.getRange(row, column).getValue();

    var timestampColumn = getTimestampColumn(currentValue);
    if (timestampColumn === null) {
      continue;
    }

    // Check if a timestamp is already present; if so, skip this row
    var existingTimestamp = sheet.getRange(row, timestampColumn).getValue();
    if (existingTimestamp) {
      continue;
    }

    // Write the timestamp
    var timestamp = new Date();
    sheet.getRange(row, timestampColumn).setValue(timestamp);

    // Move row to 'Completed' sheet if needed
    if (['Ticket reviewed', 'Unnecessary', 'Saved'].includes(currentValue)) {
      moveRowToCompleted(sheet, row);
    }
  }
}

function getTimestampColumn(value) {
  switch (value) {
    case 'Assigned':
      return 50;
    case 'Processed':
      return 51;
    case 'Ticket reviewed':
    case 'Unnecessary':
    case 'Saved':
      return 52;
    default:
      return null;
  }
}

function moveRowToCompleted(sheet, rowIndex) {
  var numColumns = sheet.getMaxColumns();
  var rowValues = sheet.getRange(rowIndex, 1, 1, numColumns).getValues();
  
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var targetSheet = ss.getSheetByName('Archived');
  var targetLastRow = targetSheet.getLastRow();
  targetSheet.getRange(targetLastRow + 1, 1, 1, numColumns).setValues(rowValues);
  
  // Clear the row content instead of deleting the row to avoid concurrent modification issues
  sheet.getRange(rowIndex, 1, 1, numColumns).clearContent();
}

These were the big changes:

  1. Lock removed: No need for locking since we’re using atomic
    operations.
  2. Reduced API calls: We cache values like sheet, range,
    and row before the loop starts.
  3. Clear instead of delete: Using clearContent() ensures that we’re not
    changing the row indices, which could cause issues with concurrent
    edits.

2

Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa Dịch vụ tổ chức sự kiện 5 sao Thông tin về chúng tôi Dịch vụ sinh nhật bé trai Dịch vụ sinh nhật bé gái Sự kiện trọn gói Các tiết mục giải trí Dịch vụ bổ trợ Tiệc cưới sang trọng Dịch vụ khai trương Tư vấn tổ chức sự kiện Hình ảnh sự kiện Cập nhật tin tức Liên hệ ngay Thuê chú hề chuyên nghiệp Tiệc tất niên cho công ty Trang trí tiệc cuối năm Tiệc tất niên độc đáo Sinh nhật bé Hải Đăng Sinh nhật đáng yêu bé Khánh Vân Sinh nhật sang trọng Bích Ngân Tiệc sinh nhật bé Thanh Trang Dịch vụ ông già Noel Xiếc thú vui nhộn Biểu diễn xiếc quay đĩa Dịch vụ tổ chức tiệc uy tín Khám phá dịch vụ của chúng tôi Tiệc sinh nhật cho bé trai Trang trí tiệc cho bé gái Gói sự kiện chuyên nghiệp Chương trình giải trí hấp dẫn Dịch vụ hỗ trợ sự kiện Trang trí tiệc cưới đẹp Khởi đầu thành công với khai trương Chuyên gia tư vấn sự kiện Xem ảnh các sự kiện đẹp Tin mới về sự kiện Kết nối với đội ngũ chuyên gia Chú hề vui nhộn cho tiệc sinh nhật Ý tưởng tiệc cuối năm Tất niên độc đáo Trang trí tiệc hiện đại Tổ chức sinh nhật cho Hải Đăng Sinh nhật độc quyền Khánh Vân Phong cách tiệc Bích Ngân Trang trí tiệc bé Thanh Trang Thuê dịch vụ ông già Noel chuyên nghiệp Xem xiếc khỉ đặc sắc Xiếc quay đĩa thú vị
Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa
Thiết kế website Thiết kế website Thiết kế website Cách kháng tài khoản quảng cáo Mua bán Fanpage Facebook Dịch vụ SEO Tổ chức sinh nhật