Spreadsheets make terrible message buses — until you need a visible, editable backlog that non-engineers can triage. A Jobs tab with pending → claimed → done|error is the bootstrapper pattern that survives Friday deploys: one worker claims a row under a lock, does the slow work outside the lock, then writes a terminal status. Here's how to wire it without inventing a second database.
TL;DR
- Columns: identity + payload +
status(+ optionalclaimedBy/claimedAt/lastError). - Claim the first
pendingrow under a document lock; returnnullwhen empty. - Do UrlFetch / Drive work after the claim; then set
doneorerror. - Never "find pending and process" without flipping status first — that's how doubles happen.
- Menu + time-driven trigger can share the same
processOneQueueJobentry point.
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.
Why use a sheet as a queue at all?
Because your operators already live there. They can:
- Paste tomorrow's batch as rows
- Re-open a failed job by setting
statusback topending - Filter
errorwithout opening Cloud Logging
You're trading throughput for visibility. For dozens-to-low-hundreds of jobs per day, that's usually the right trade. Past a few thousand hot rows, graduate to a proper queue — until then, keep the sheet honest.
What columns does a job queue need?
Minimum that works:
id | type | payload | status | claimedBy | claimedAt | lastError | updatedAt
Rules of thumb:
statusvalues are a small enum:pending,claimed,done,error(lowercase, exact).payloadcan be a JSON string or a few typed columns — pick one style and stick to it.- Don't delete done rows on day one; archive weekly if the tab gets heavy.
function ensureJobsSheet_(ss) {
var sheet = ss.getSheetByName('Jobs');
var headers = [
'id', 'type', 'payload', 'status',
'claimedBy', 'claimedAt', 'lastError', 'updatedAt'
];
if (!sheet) {
sheet = ss.insertSheet('Jobs');
sheet.appendRow(headers);
sheet.setFrozenRows(1);
return sheet;
}
ensureHeaders(sheet, headers);
return sheet;
}
How do you claim the next job safely?
Two workers reading "first pending" at the same millisecond will both process it unless the claim is atomic. Apps Script gives you LockService — use it for the read + status flip, not for the entire UrlFetch.
function claimNextQueueRow(sheet, statusHeader, claimedBy, options) {
options = options || {};
var pending = options.pendingStatus || 'pending';
var claimed = options.claimedStatus || 'claimed';
var headerRow = options.headerRow || 1;
return withDocumentLock(function () {
var lastCol = sheet.getLastColumn();
var lastRow = sheet.getLastRow();
if (lastRow <= headerRow) return null;
var headers = sheet.getRange(headerRow, 1, 1, lastCol).getValues()[0];
var statusCol = headers.indexOf(statusHeader);
if (statusCol === -1) {
throw new Error('claimNextQueueRow: missing status header "' + statusHeader + '"');
}
var values = sheet.getRange(headerRow + 1, 1, lastRow - headerRow, lastCol).getValues();
for (var i = 0; i < values.length; i++) {
if (String(values[i][statusCol]).toLowerCase() !== String(pending).toLowerCase()) {
continue;
}
var row = headerRow + 1 + i;
sheet.getRange(row, statusCol + 1).setValue(claimed);
if (options.claimedByHeader) {
var byCol = headers.indexOf(options.claimedByHeader);
if (byCol !== -1) {
sheet.getRange(row, byCol + 1).setValue(
claimedBy || Session.getActiveUser().getEmail() || 'unknown'
);
}
}
if (options.claimedAtHeader) {
var atCol = headers.indexOf(options.claimedAtHeader);
if (atCol !== -1) sheet.getRange(row, atCol + 1).setValue(new Date());
}
var obj = { _row: row };
for (var c = 0; c < headers.length; c++) {
var key = String(headers[c] == null ? '' : headers[c]).trim();
if (!key) continue;
obj[key] = values[i][c];
}
obj[statusHeader] = claimed;
return obj;
}
return null;
});
}
Empty queue → null. That's success for a polling trigger, not an error.
How should the worker loop look?
Claim → work → terminal status. Catch failures onto the row so operators can retry.
function processOneQueueJob() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ensureJobsSheet_(ss);
var job = claimNextQueueRow(sheet, 'status', null, {
claimedByHeader: 'claimedBy',
claimedAtHeader: 'claimedAt'
});
if (!job) {
ss.toast('Queue empty', 'Jobs', 3);
return null;
}
try {
var result = runJob_(job); // UrlFetch, Drive, sheet writes — outside the claim lock
writeJobStatus_(sheet, job._row, 'done', '');
return result;
} catch (err) {
writeJobStatus_(sheet, job._row, 'error', err.message || String(err));
emailErrorAlert('queue job failed', err, { row: job._row, id: job.id, type: job.type });
throw err;
}
}
function writeJobStatus_(sheet, row, status, lastError) {
var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
function col(name) { return headers.indexOf(name); }
sheet.getRange(row, col('status') + 1).setValue(status);
if (col('lastError') !== -1) sheet.getRange(row, col('lastError') + 1).setValue(lastError || '');
if (col('updatedAt') !== -1) sheet.getRange(row, col('updatedAt') + 1).setValue(new Date());
}
How do you drain multiple jobs without timeouts?
Apps Script executions have a wall clock. Prefer one job per trigger tick for reliability, or a bounded loop:
function processQueueBatch(maxJobs) {
maxJobs = maxJobs || 5;
var done = 0;
for (var i = 0; i < maxJobs; i++) {
var result = processOneQueueJob();
if (result === null && i === 0) break; // empty on first claim
// If processOneQueueJob returns null only when empty *before* work,
// track a boolean instead — adjust to your helper.
done++;
}
return done;
}
For long batches, a 1–5 minute time-driven trigger that processes N jobs beats a single 5-minute mega-run that dies at 99%.
What about retries and poison messages?
- Operator retry: set
statusback topending, clearlastError. - Auto-retry: increment an
attemptscolumn; after 3 failures leave iterrorand alert. - Poison payload: don't infinite-loop — terminal
errorplus email is the grown-up move.
Stuck claimed rows (worker died mid-flight) need a sweeper: if claimedAt is older than 30 minutes and status is still claimed, either re-queue or mark error with reason stale-claim.
function requeueStaleClaims_(sheet, maxAgeMs) {
maxAgeMs = maxAgeMs || 30 * 60 * 1000;
var data = sheet.getDataRange().getValues();
var headers = data[0];
var statusCol = headers.indexOf('status');
var atCol = headers.indexOf('claimedAt');
var now = Date.now();
for (var r = 1; r < data.length; r++) {
if (String(data[r][statusCol]).toLowerCase() !== 'claimed') continue;
var at = data[r][atCol];
if (!(at instanceof Date)) continue;
if (now - at.getTime() < maxAgeMs) continue;
sheet.getRange(r + 1, statusCol + 1).setValue('pending');
}
}
Minimal test plan
- Two overlapping
processOneQueueJobcalls claim different rows (or one getsnull) - Failed work leaves
error+lastError, not a silentclaimed - Empty queue returns quietly
- Manual
pendingreset re-runs the job once - Stale
claimedsweeper only touches old rows
Soft Co-Pilot note
If the claim/lock boundary is fuzzy in your workbook, NitroGAS ships claimNextQueueRow and lock helpers as snippets — Co-Pilot can help adapt status enums to an existing Ops tab. Free extension; Co-Pilot optional.
Closing checklist
- Jobs tab headers frozen; status enum documented for operators
- Claim under document lock; work outside it
- Terminal
done/errorwrites include timestamps - Alert on failure (sheet log + email)
- Stale-claim policy written down (even if manual at first)
- Trigger schedule matches batch size (don't boil the ocean)
Happy Coding!


