Robert O'Neill ·
We tested Google Sheets and Excel Online. Their APIs silently overwrote our data.
More and more software writes to spreadsheets: automations, sync jobs, and lately AI agents working on the same files as the people who own them. Almost all of it follows one pattern: read the sheet, compute something, write the result back.
We wanted to know what happens when the sheet changes between the read and the write. So we ran the same test against three spreadsheet APIs.
The test
Two writers read the same cell. Writer A writes a new value. Then writer B, which never saw A’s change, writes its own value to the same cell.
| Writer A | Writer B (stale) | What’s left in the cell | |
|---|---|---|---|
Google Sheets API (values.update) | 200 OK | 200 OK | B’s value. A’s edit is gone, with no error. |
| Excel Online, Microsoft Graph (range PATCH) | 200 OK | 200 OK | B’s value. A’s edit is gone, with no error. |
VisiGrid API (write_cells) | 200 OK | 409 revision_conflict | A’s value. B is told to reread. |
This isn’t a race we had to get lucky to hit. It reproduces every time, because
neither API can express the condition. Sheets’ values.update has no revision or
precondition parameter (reference).
Graph’s range update takes no If-Match (reference).
Graph supports ETags on whole-file uploads, but not on cell edits.
To be clear about scope: this is about the APIs. People editing together in the browser are fine in all three products, which merge edits live. The problem is the program that read a minute ago and writes now.
Reproduce it
The full scripts are at github.com/VisiGrid/stale-write-test.
They use only curl and jq. The Google Sheets half is just this:
url="https://sheets.googleapis.com/v4/spreadsheets/$SHEET_ID/values/A1?valueInputOption=RAW"
put() { curl -s -o /dev/null -w '%{http_code}\n' -X PUT "$url" -H "Authorization: Bearer $GOOGLE_TOKEN" \
-H 'Content-Type: application/json' -d "{\"values\":[[\"$1\"]]}"; }
put "original" # both writers read "original"
put "writer A" # 200
put "writer B" # 200, computed from "original"; A's edit is now gone
The VisiGrid half differs in one field. Every write names the revision it was computed from:
curl -X POST "https://app.visigrid.app/api/connector/sheets/$SHEET_ID/cells" \
-H "Authorization: Bearer $VISIGRID_TOKEN" -H 'Content-Type: application/json' \
-d '{"expected_revision": 7, "edits": [{"ref": "A1", "value": "writer B"}]}'
# 409 revision_conflict: A already moved the workbook to revision 8
“Why are you using a spreadsheet for this?”
Fair question; we’d ask it too. The honest answer is that nobody chose the spreadsheet for this. The budget, the inventory list and the pipeline tracker already live in one, because the people who own the data can read and edit it without an engineer. Now software is writing to those same files the way it writes to a database, without the one guarantee every database has had for decades: compare-and-swap. That means a write that only applies if nothing changed since you read.
That’s what we built. Every VisiGrid write carries the revision it was computed from. If anything changed since (a person in the browser, another script, an agent), the write is refused with a 409, and the caller rereads and retries. Writes are atomic (up to 1,000 cells in one call), formulas are computed by the real engine before the response returns, and the response tells you if your write replaced a formula with a value.
”Isn’t last-write-wins just documented behaviour?”
Yes, and we’re not calling it a bug. The point is that the APIs give you no way to ask for anything else on a cell write, and they don’t tell you when it happened. If you’re wiring an agent or a sync job into a shared sheet, you’re one stale read away from erasing someone’s work without either of you knowing.
The other wall: write quotas
Every Sheets integrator eventually hits Google’s write quota: 60 write requests a minute per user. Here are the published limits side by side:
| Write limit | Cells per write | Over the limit | |
|---|---|---|---|
| Google Sheets API | 60 a minute, per user | one range | 429 “Quota exceeded … Write requests” |
| VisiGrid API | 300 a minute, per token | up to 1,000, atomic | 429 with Retry-After |
We measured both. One client wrote 100-cell blocks as fast as each API answered, for two minutes. (Google Sheets and VisiGrid only; see “Method”.)
| Writes in 2 minutes | Refused | |
|---|---|---|
| Google Sheets API | 170 (about 85 a minute) | 96, starting after 25 writes |
| VisiGrid API | 600 (exactly 300 a minute) | 42, starting after 300 writes, each saying when to retry |
Both APIs have a wall; ours is five times further out and tells you when it lifts. It’s a policy, not a ceiling: before the limit existed, the same test sustained 633 writes a minute from one client.
And per-call latency (p50 / p95, ms, from the same machine, 15 runs each):
| Google Sheets | VisiGrid | |
|---|---|---|
| read 1 cell | 110 / 171 | 105 / 109 |
| write 1 cell | 115 / 144 | 109 / 112 |
| write 100 cells | 120 / 131 | 116 / 137 |
| write 1,000 cells | 121 / 254 | 177 / 461 |
| 4,000-row sheet: write a cell, read the recalculated total | 221 / 263 | 433 / 493 |
Small calls are about even, with VisiGrid steadier at the tail. We’re not faster at everything: Google wins on large writes and big recalculations, by about 2× on the 4,000-row sheet. Our engine recomputes the whole sheet on each write, and incremental recalculation is next.
Method and caveats
- When: 2026-10-08.
- Where: all three from one client machine in the US, against each service’s production API. VisiGrid’s production is one server with 1.5 CPU cores; Google and Microsoft run on global infrastructure.
- Google: a service-account client writing to a spreadsheet shared with it. Default quotas, nothing raised.
- Excel Online: Microsoft Graph, delegated access, with one persistent workbook session. Microsoft’s API terms don’t allow performance testing of its services without a written agreement, so for Excel we report only the stale-write behaviour, and no timings.
- VisiGrid: a personal API token (read and write) on the same connector API that Claude and other MCP clients use.
- Sustained test: one client, one request at a time. Google’s write quota is per user, so a single client is the relevant case. A 429 is counted as a refusal and the client waits a second before its next write.
- The stale-write test is deterministic. It doesn’t depend on timing, load or scale.
- Harness: the latency, quota and recalculation numbers come from
bench.mjsin the same repository. Run it from your machine; your numbers will differ with location.