DEV Community

Luke Sandelands
Luke Sandelands

Posted on

I replaced $700/month of SaaS with Google Apps Script. Here's what broke first.

A while back I looked at a stack of SaaS subscriptions that were, between them, doing something fairly unglamorous: moving rows of data from one system into another and doing arithmetic on them. The combined bill was around $700 a month.

So I rewrote the lot in Google Apps Script and a webhook layer. It worked immediately, which was the problem — it worked at ten events a day and quietly stopped working at four hundred, in ways that produced no error email and no obvious symptom.

Here are the four things that broke, in the order they broke, and what actually fixed them.

1. Per-call service quotas, not row counts

The first version looked like every Apps Script tutorial:

function logEvents(events) {
  const sheet = SpreadsheetApp.getActiveSheet();
  events.forEach(e => {
    sheet.appendRow([e.id, e.timestamp, e.amount, e.source]);
  });
}
Enter fullscreen mode Exit fullscreen mode

This is fine for ten rows and fatal for a thousand. appendRow is a service call, and Apps Script quotas count service calls, not rows. Four hundred appends is four hundred round trips to the Sheets backend, each with its own latency, and you hit Service invoked too many times for one day long before you hit anything resembling a real data volume.

One call instead of four hundred:

function logEvents(events) {
  if (!events.length) return;
  const sheet = SpreadsheetApp.getActiveSheet();
  const rows = events.map(e => [e.id, e.timestamp, e.amount, e.source]);
  sheet
    .getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length)
    .setValues(rows);
}
Enter fullscreen mode Exit fullscreen mode

Same output, one service call. This single change took a script that died daily to one that hasn't hit a quota in months.

The general rule: in Apps Script, anything that touches SpreadsheetApp, DriveApp, GmailApp or UrlFetchApp inside a loop is a bug waiting for volume. Build the array in memory, write once.

2. Duplicate writes, because webhooks retry

Every webhook provider retries on non-2xx, and some retry on timeout even when your handler succeeded. If your endpoint takes 30 seconds because of point 1 above, you will get the same event twice, and you will write it twice.

You need idempotency, and you need it at the write layer rather than the transport layer — you cannot control how many times you get called, only what you do about it.

function handleEvent(event) {
  const cache = CacheService.getScriptCache();
  const key = 'evt_' + event.id;

  if (cache.get(key)) return;        // already processed
  cache.put(key, '1', 21600);        // 6h TTL, the cache maximum

  writeEvent(event);
}
Enter fullscreen mode Exit fullscreen mode

CacheService is the right tool here rather than PropertiesService, because it expires on its own and you are not trying to keep a permanent ledger — you are trying to survive a retry storm that lasts seconds. For anything needing a longer window, reconcile against the sheet itself on a nightly trigger and dedupe on the ID column.

One caveat worth knowing: CacheService is best-effort and can evict early under pressure. It is a retry guard, not a correctness guarantee. If duplicates are genuinely unacceptable, you need the nightly reconciliation as well.

3. Concurrent executions racing for the same row

Two webhooks arriving 200ms apart both call getLastRow(), both get 4,102, and both write to row 4,103. One of them wins. The other event is gone, with no error anywhere.

This is the failure mode that cost me the most time, because it produces silent, occasional data loss that you only notice weeks later when a total doesn't reconcile.

function writeEvent(event) {
  const lock = LockService.getScriptLock();

  try {
    lock.waitLock(30000);            // throws if it can't acquire
    appendRows([event]);
  } catch (e) {
    console.error('Lock timeout, requeueing: ' + event.id);
    queueForRetry(event);            // don't silently drop it
    return;
  } finally {
    lock.releaseLock();
  }
}
Enter fullscreen mode Exit fullscreen mode

getScriptLock() rather than getUserLock() — the lock needs to span every execution of the script, not every execution by a given user. And note the finally: an early return inside the try block without releasing the lock will stall every subsequent execution for the full 30 seconds.

4. The six-minute wall

Apps Script kills any execution at six minutes. If you are backfilling, migrating, or processing a queue that grew overnight, you will hit this, and the execution dies mid-write with no transaction to roll back.

The pattern is checkpointing: process until you are near the limit, save your position, schedule a continuation, exit cleanly.

const MAX_RUNTIME_MS = 4.5 * 60 * 1000;   // leave 90s of headroom

function processQueue() {
  const start = Date.now();
  const props = PropertiesService.getScriptProperties();
  let cursor = Number(props.getProperty('cursor') || 0);

  const items = getQueue();

  while (cursor < items.length) {
    if (Date.now() - start > MAX_RUNTIME_MS) {
      props.setProperty('cursor', String(cursor));
      scheduleContinuation();
      return;
    }
    processItem(items[cursor]);
    cursor++;
  }

  props.deleteProperty('cursor');            // finished cleanly
}

function scheduleContinuation() {
  ScriptApp.newTrigger('processQueue')
    .timeBased()
    .after(60 * 1000)
    .create();
}
Enter fullscreen mode Exit fullscreen mode

There's more to this than one code block holds once you're processing a backlog rather than a steady queue — batch sizing, resumability after a failed continuation, and reconciling a partial run. I wrote those up separately in scalable Google Sheets automation for high-volume workflows.

Two things that bite people here. The headroom matters — if your per-item work takes eight seconds, a 30-second buffer isn't enough and you'll die mid-item. And time-based triggers are themselves a quota'd resource, capped at 20 per script, so delete the trigger once it has fired or you will accumulate orphans until trigger creation starts failing.

function deleteTriggersFor(handlerName) {
  ScriptApp.getProjectTriggers()
    .filter(t => t.getHandlerFunction() === handlerName)
    .forEach(t => ScriptApp.deleteTrigger(t));
}
Enter fullscreen mode Exit fullscreen mode

The backoff you need for external calls

Worth adding for anything calling an external API, since transient 429s and 503s will otherwise surface as permanent failures:

function fetchWithBackoff(url, params, maxAttempts = 5) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const res = UrlFetchApp.fetch(url, {
      ...params,
      muteHttpExceptions: true
    });
    const code = res.getResponseCode();

    if (code < 400) return res;
    if (code < 500 && code !== 429) return res;   // real client error, don't retry

    const backoff = Math.pow(2, attempt) * 1000;
    const jitter = Math.floor(Math.random() * 1000);
    Utilities.sleep(backoff + jitter);
  }
  throw new Error('Max retries exceeded: ' + url);
}
Enter fullscreen mode Exit fullscreen mode

The jitter is not decoration. Without it, every one of your parallel executions retries at exactly the same moment and you rebuild the thundering herd you were trying to avoid.

What I'd tell myself at the start

The SaaS tools I replaced were not charging $700 a month for the arithmetic. They were charging for the four things above — retry handling, deduplication, concurrency safety, and job continuation — which is genuinely the hard part and which is invisible until you are running at volume.

That doesn't make the replacement a bad trade. It cost me maybe 40 hours of debugging spread over a few months, against roughly $8,400 a year, and I now own the thing outright and can read every line of it. But it's a real trade, not a free lunch, and anyone who tells you a Sheets-and-webhooks stack "just replaces" a SaaS subscription has not yet run it at four hundred events a day.

If you're doing something similar: write these four patterns in before you have the volume that proves you needed them. The failures are silent, and by the time you notice, the missing data is already weeks old.


What's the most annoying quota wall you've hit in Apps Script? I'm fairly sure I haven't found all of them yet.

Top comments (0)