Google Sheets Missing Rows / Race Condition During Bulk Webhook Triggers

Hi everyone,

I’m facing a concurrency/race condition issue with a simple workflow, and I’d appreciate some advice on the best way to handle it.

My Setup:

I have a straightforward workflow: Webhook (POST) $\rightarrow$ Google Sheets (Append Row).

Its purpose is to automatically log media URLs (images/videos) into a Google Sheet using the expression {{ $json.body.secure_url }} sent via the webhook payload.

The Problem:

  • When files are uploaded one by one (single webhook trigger), everything works perfectly, and every row is logged.

  • However, when I perform a bulk upload (uploading multiple videos/images at the exact same time), the webhook receives multiple requests simultaneously.

  • As a result, Google Sheets only logs a few rows and misses the rest. It seems like a classic race condition where the Google Sheets API fails to determine the next empty row because multiple requests are trying to write at the exact same millisecond. Interestingly, all executions show as Success inside n8n.

Here is my workflow JSON for reference:

JSON

Welcome @frendy_nicodemus to our community! I’m Jay and I am a n8n verified creator.

The root cause is that Google Sheets’ Append Row API is not atomic - when multiple calls arrive at the same time, they can write to the same target row. The cleanest fix without changing infrastructure: enable “Respond to Webhook” immediately (return 200 right away) and add a Wait node with a small random delay (e.g. 0-2 seconds) before the Sheets step to stagger concurrent writes. For a more robust solution, switch to a queue-based approach - write to a Postgres table first, then a separate scheduled workflow reads and appends to Sheets in sequence. The database write is atomic and you won’t lose rows.

hi @frendy_nicodemus, good morning!

A less palliative alternative is to stop relying on the “next empty space” in the spreadsheet. I would add a unique uploadId to each payload and save it along with the URL, so you can later reconcile which uploads reached n8n but didn’t appear in Sheets. It’s also worth using Sheets only as a final report destination, not as primary storage. For bulk loading, I’d keep the reliable source in Postgres/Data Table and run a synchronization routine with retry and deduplication by uploadId.

@frendy_nicodemus

Can you try this?

Does that reduce your race condition?

One extra thing I would add to the good suggestions above: treat this as a reconciliation problem, not only a concurrency problem.

A practical minimal setup:

1. On webhook receive, immediately write an intake record with a stable uploadId, secure_url, receivedAt, and batchId to a durable store (Postgres/Data Table; even a small queue table is fine).

2. Let one scheduled/queued writer append to Google Sheets sequentially.

3. After each batch, compare expected_upload_count vs sheet_written_count by uploadId and alert/report the missing IDs. That catches the dangerous case here: n8n executions are green but the business record is absent.

4. Make retries idempotent: before appending, check whether uploadId was already written, so a retry cannot duplicate rows.

If you stay on Sheets only, the random Wait can reduce collisions, but I would still keep the uploadId reconciliation. Otherwise you can get a green execution history and still not know which media URLs never landed.

If you can share a sanitized example of the payload fields (fake URLs are fine) and whether each bulk upload has a batch/job id, people can suggest the smallest queue/reconciliation shape.

unfortunately this workflow doesn’t work well.. i’ve tried this before.. the real solution is using postgres