●PRO35 — Gemini 3.5 Pro is reported to target July 17, though the date, context window, and pricing all remain unconfirmed by Google●REBUILD — The model is said to be a ground-up rebuild after the 2.5 Pro architecture was scrapped over structural failures in recursive tool-calling and SVG generation●FLASH35 — Gemini 3.5 Flash is the newest broadly available release, tuned for faster, lower-cost coding, agents, multimodal, and long-document work●SAATHI — ATL Saathi, built on Gemini 3.5 Flash, begins piloting in 100 Indian schools as a 24/7 planning and training assistant for Tinkering Lab educators●SEA — In Southeast Asia, nearly 70% of prompts arrive in native languages — Vietnam 89%, Thailand 87%, Indonesia 84% — and 3 in 4 requests come from mobile●SHEETS — From July 15, AI Expanded Access licenses raise the usage limits for Fill with Gemini and the AI function in Sheets●PRO35 — Gemini 3.5 Pro is reported to target July 17, though the date, context window, and pricing all remain unconfirmed by Google●REBUILD — The model is said to be a ground-up rebuild after the 2.5 Pro architecture was scrapped over structural failures in recursive tool-calling and SVG generation●FLASH35 — Gemini 3.5 Flash is the newest broadly available release, tuned for faster, lower-cost coding, agents, multimodal, and long-document work●SAATHI — ATL Saathi, built on Gemini 3.5 Flash, begins piloting in 100 Indian schools as a 24/7 planning and training assistant for Tinkering Lab educators●SEA — In Southeast Asia, nearly 70% of prompts arrive in native languages — Vietnam 89%, Thailand 87%, Indonesia 84% — and 3 in 4 requests come from mobile●SHEETS — From July 15, AI Expanded Access licenses raise the usage limits for Fill with Gemini and the AI function in Sheets
I Put =GEMINI() in 420 Cells. The Calls Kept Coming on Days I Never Opened the Sheet
A custom function calling Gemini from Sheets looks best the moment it starts working, and it does not grow from there. Here is what the 30-second ceiling, uncontrolled recalculation, and read-only cells actually cost, measured, plus the code to move to a write-back design.
The cell note just said Exceeded maximum execution time (line 0). Thirty-seven cells out of 420 showed #ERROR!, scattered with no pattern I could see. Same prompt, same model, inputs of roughly the same length. Nothing was wrong with those particular rows. What was wrong was the arrangement: 420 formulas sitting next to each other in a column.
I had been collecting app review text in a spreadsheet — column C for the review, column D for =GEMINI_SUMMARY(C2). As an indie developer running this kind of thing solo, the appeal is obvious: no deployment, no backend, and the answer appears in the cell while you watch.
Ten rows worked beautifully. Dragging the formula down to 420 rows surfaced everything this shape had been hiding.
The #ERROR! cells were not the real problem. At the end of that week I looked at the usage numbers: I had written 420 formulas, and the script had made 1,180 calls. The count went up on mornings when nobody had touched the file.
Every cell is its own separate call
The optimization section of the official documentation states the core of it plainly: each time a custom function is used in a spreadsheet, Sheets makes a separate call to the Apps Script server. Dozens, hundreds, or thousands of custom function calls in one sheet make that process slow.
So pasting =GEMINI_SUMMARY(C2) into 420 cells is not one job processing 420 rows. It is 420 independent executions embedded in a spreadsheet, each with its own 30-second budget.
The difference shows up when things fail, and when things amplify. A 420-row batch can hold "how far did we get" in one place. Four hundred and twenty independent executions have no concept of progress at all. There are only cells that happen to contain a value and cells that happen to say #ERROR!.
Google publishes a custom function sample that fact-checks statements using an ADK agent and a Gemini model, and it is a good on-ramp. The trouble starts when you try to extend the on-ramp into a road.
The calls that arrive while you sleep
Recalculation does not happen when you decide it should. The documentation is specific: to trigger recalculation you must pass a referenced cell or range directly as an argument; otherwise the function will not recalculate until you edit the formula or change a referenced cell's value.
Read that the other way around. As long as you pass a referenced cell directly, every change to that reference triggers a fresh call. In my sheet, touching column C re-ran column D. Fixing a typo. Inserting a language column. Moving a column to sort. Every one of those is an edit.
There is a related constraint worth knowing: arguments must be deterministic. Volatile built-ins like NOW() or RAND() cannot be passed to a custom function — try it and the cell shows Loading... forever. The natural workaround is to park a fixed timestamp in a cell instead, and the moment you refresh that timestamp, you have pulled the recalculation trigger yourself.
Here is my week, counted. These are my numbers on my sheet, and they will move depending on how you work. Treat them as a shape to re-measure, not a benchmark.
Trigger
Times
Rows recalculated
Calls
Initial fill across 420 rows
1
420
420
Typo fixes in column C (one cell at a time)
23
23
23
Inserting and removing a language column
2
420
654*
Saving prompt tweaks in the script
4
—
83
Total
—
—
1,180
* Not every row re-runs every time; some keep their cached value. That inconsistency is exactly what makes this hard to see — the sheet never tells you how many times it called.
Formulas written: 420. Actual calls: 1,180. A 2.8x multiplier. What matters is not the size of the multiplier but that it is disconnected from anything I chose to do. Inserting one column — an action with no apparent cost — quietly billed a few hundred requests.
To count it on your own sheet, one line on the calling side is enough.
/** * Summarizes review text (instrumented — this shape is not for production). * * @param {string} text The review body to summarize. * @return {string} The summary text. * @customfunction */function GEMINI_SUMMARY(text) { // Record the fact of the call first. Count them in the Apps Script executions view. console.log('GEMINI_SUMMARY invoked len=%s', String(text).length); const key = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY'); const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + 'gemini-flash-latest:generateContent?key=' + key; const res = UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: JSON.stringify({ contents: [{ parts: [{ text: 'Summarize this review in one sentence.\n\n' + text }] }], }), muteHttpExceptions: true, }); if (res.getResponseCode() !== 200) { // A custom function can only return a value. The cause dies here. return '#GEMINI_ERROR'; } const json = JSON.parse(res.getContentText()); return json.candidates[0].content.parts[0].text.trim();}
Paste it, use the sheet normally for a week, then compare the execution count in the Apps Script dashboard against the number of formulas in the sheet. For me, that gap settled the argument faster than any reasoning about architecture did.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A measurement recipe for counting the recalculation amplification your sheet generates on its own, plus the breakdown behind a measured 2.8x on 420 rows
✦Why the 30-second custom function ceiling is a different limit from the 6-minute one, and how to turn your per-row latency into a hard cap on rows per call
✦Complete menu-driven batch code for moving from formulas-in-cells to a status column with written-back values, and a decision table for when a custom function is still the right shape
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
These two limits get conflated constantly, so let us separate them. The documentation is unambiguous: a custom function call must return within 30 seconds. If it does not, the cell displays #ERROR! and the cell note reads Exceeded maximum execution time (line 0).
A normal script started from a menu or a trigger gets six minutes. Same Apps Script, but custom functions run on one-twelfth of the budget. Those 37 error rows were not slow prompts or long inputs. They were simply the 37 rows that happened to cross 30 seconds during a busy stretch.
That 30 seconds also decides the ceiling for the next section. On my setup, a round trip to gemini-flash-latest for a short summary took roughly 1.7 to 2.1 seconds per row. Call it 2.0 to be safe, and 30 seconds holds about 15 rows. Discount to 70 percent for real-world variance and the practical cap is around 10.
Execution type
Time limit
What exceeding it looks like
Custom function (=GEMINI_SUMMARY())
30 seconds
#ERROR! in the cell, message in the cell note
Menu / installable trigger
6 minutes
Exception in the log, partial write-back retained
Trimming the prompt, capping output tokens, dropping to a lighter model — I tried all of it, and all of it helps. Add rows and you meet the same wall again. Since the ceiling will not move, I have come to prefer moving the design out from under it rather than getting cleverer beneath it.
Take a range, return a 2D array
Before rebuilding anything, there is an order-of-magnitude win the documentation hands you for free. A custom function can accept a range as a two-dimensional array and return a two-dimensional array that overflows into adjacent cells. Instead of 420 formulas in 420 cells, you write one formula in one cell that returns 420 rows.
/** * Takes a range of review bodies and returns a single column of summaries. * Calls the server once, not once per cell. * * @param {Array<Array<string>>} range The range of review bodies. * @return {Array<Array<string>>} One column of summary text. * @customfunction */function GEMINI_SUMMARY_RANGE(range) { const rows = (Array.isArray(range) ? range : [[range]]) .map((row) => String(row[0] || '').trim()); // Claim the 30-second budget up front. Mark the rest rather than dying. const deadline = Date.now() + 25 * 1000; const out = []; for (const text of rows) { if (!text) { out.push(['']); continue; } if (Date.now() > deadline) { out.push(['#BUDGET']); continue; } out.push([summarizeOne_(text)]); } return out;}function summarizeOne_(text) { const key = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY'); const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + 'gemini-flash-latest:generateContent?key=' + key; const res = UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: JSON.stringify({ contents: [{ parts: [{ text: 'Summarize this review in one sentence.\n\n' + text }] }], generationConfig: { maxOutputTokens: 120 }, }), muteHttpExceptions: true, }); if (res.getResponseCode() !== 200) return '#GEMINI_ERROR'; return JSON.parse(res.getContentText()).candidates[0].content.parts[0].text.trim();}
With =GEMINI_SUMMARY_RANGE(C2:C11), the sheet makes one call to the Apps Script server. The visible layout barely changes and the call count drops by two orders of magnitude.
The deadline line is where the shape shows its limit, though. Thirty seconds caps how many rows the range can hold — around 10 to 12 at my measured 2.0 seconds per row. Push past that and the tail fills with #BUDGET. You could split 420 rows across 42 formulas of 10 rows each, but that is just 42 stacked 30-second budgets, and the recalculation amplification is untouched.
This shape belongs in exploration. While you are still deciding prompt wording and output granularity across a few dozen rows, seeing results land in the sheet as you type is genuinely worth something. I use it right up until the prompt settles. Then I move it out.
Three things a custom function cannot see
Separate from row counts, the custom function execution model carries three constraints that only bite in production. They sit quietly in a documentation table, invisible while everything works.
It cannot write back. The Spreadsheet service is read-only inside a custom function: get*() methods work, set*() methods do not, and SpreadsheetApp.openById() and openByUrl() are unavailable. On top of that, a custom function cannot affect any cell other than the ones it returns a value to. So "mark this row done in another column" or "log failures to a ledger sheet" — the bookkeeping any batch does by default — is structurally impossible.
Failures collapse into values. All you can return is something displayable, which is why my code returns the string #GEMINI_ERROR. Rate limit? Key restriction? Malformed schema? The cell does not distinguish. console.log puts it in the execution log, but anyone looking at the sheet learns only that something broke.
The key has nowhere safe to live. Custom functions never prompt for authorization, which is why they cannot reach services that touch personal data in the first place. Properties works, but getUserProperties() returns only the spreadsheet owner's properties, and editors cannot set user properties inside a custom function. More to the point, a container-bound script shares its container's access list. Sharing the sheet with edit access means sharing the ability to edit the script. An API key in script properties is three lines of code away from anyone on that list. Handing out a homemade =GEMINI() on a shared sheet is close to handing out the key.
What you want
Custom function
Menu / trigger execution
Write results to arbitrary cells or sheets
No (read-only, returned cells only)
Yes
Stay idempotent via a done flag
No
Yes
Record failures by category
Execution log only, never the sheet
Yes, in a ledger sheet
Decide when execution happens
No — recalculation decides
Yes
Use services requiring authorization
No
Yes
Make the cell a request, not a call
The migration fits in one sentence: change what the cell holds from a call into a state. Column D holds a value, not a formula. Column E records when it was produced and by which model. The only thing that calls Gemini is a batch you started from a menu. Recalculation amplification goes to zero, because values do not recalculate no matter who inserts a column.
const SHEET_NAME = 'reviews';const COL = { text: 3, summary: 4, status: 5, model: 6, at: 7 };function onOpen() { SpreadsheetApp.getUi() .createMenu('Gemini') .addItem('Summarize pending rows', 'runSummaryBatch') .addToUi();}/** * Summarizes only unprocessed rows and writes results back as values. * If it hits the 6-minute wall, everything written so far stands. * Run it again and it resumes. */function runSummaryBatch() { const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME); const last = sheet.getLastRow(); if (last < 2) return; const values = sheet.getRange(2, 1, last - 1, COL.at).getValues(); const deadline = Date.now() + 5 * 60 * 1000; // step down before the 6-minute limit let done = 0; for (let i = 0; i < values.length; i++) { if (Date.now() > deadline) break; const row = i + 2; const text = String(values[i][COL.text - 1] || '').trim(); const status = String(values[i][COL.status - 1] || '').trim(); if (!text) continue; if (status === 'ok') continue; // idempotency lives here and nowhere else const result = summarizeWithRetry_(text); // Write each row as it completes. A crash never costs completed work. sheet.getRange(row, COL.summary, 1, 4).setValues([[ result.ok ? result.text : '', result.ok ? 'ok' : 'error:' + result.reason, 'gemini-flash-latest', new Date(), ]]); done++; } SpreadsheetApp.getActive().toast(done + ' rows processed', 'Gemini', 5);}function summarizeWithRetry_(text) { const key = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY'); const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + 'gemini-flash-latest:generateContent?key=' + key; const payload = JSON.stringify({ contents: [{ parts: [{ text: 'Summarize this review in one sentence.\n\n' + text }] }], generationConfig: { maxOutputTokens: 120 }, }); for (let attempt = 0; attempt < 4; attempt++) { const res = UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: payload, muteHttpExceptions: true, }); const code = res.getResponseCode(); if (code === 200) { const parts = JSON.parse(res.getContentText()).candidates?.[0]?.content?.parts; if (!parts || !parts[0]?.text) return { ok: false, reason: 'empty' }; return { ok: true, text: parts[0].text.trim() }; } // Only 429 and 5xx are worth waiting on. Resending a 4xx changes nothing. if (code !== 429 && code < 500) return { ok: false, reason: 'http' + code }; Utilities.sleep(Math.pow(2, attempt) * 1000); } return { ok: false, reason: 'retry_exhausted' };}
One status column changes the character of the whole thing. Stop at six minutes and the written rows are already final. Press the menu item again and it picks up. Failed rows carry error:http429 so you can filter for them later. Getting 420 rows through unattended — handing the baton to yourself with a trigger to cross the 6-minute wall — is a separate implementation, and I wrote it up in processing thousands of spreadsheet rows with Gemini from Apps Script. This piece is the step before that one: dropping the formula shape in the first place.
After the move, my weekly call count went from 1,180 to 420. Most of what disappeared was happening while I was doing nothing at all.
Where a custom function still earns its place
I do not think everything should be a batch. The real value of a custom function is that the answer lands where you are looking. Here is the granularity I actually use.
Situation
What I pick
Why
Still tuning the prompt, a few dozen rows
Custom function, range-taking form
Editing and seeing results instantly is worth real money
Write once, never recalculate
Menu batch with write-back
Values do not recalculate
Over 100 rows, run weekly
Menu batch with write-back
30 seconds will not hold it, and amplification compounds
Shared with editors
Batch, or Fill with Gemini
The script key is shared along with the sheet
Generic transforms like translation
Try the built-in feature first
Not writing it is the cheapest option available
That last row is not a throwaway. For work like localizing store descriptions, the built-in path is faster and safer than anything I would write. I keep the app metadata side on the Fill with Gemini workflow for multilingual store listings at Dolice, and reserve custom code for what does not fit there. Being able to build it and being better off building it are different questions.
Start by counting one sheet
You do not need an architecture argument to make this call. Pick one sheet that currently has =GEMINI()-style formulas in it, add the console.log line above, and use it normally for a week. Then put two numbers side by side: executions in the Apps Script dashboard, and formulas in the sheet. The wider that gap, the clearer the case for writing values back.
I ran the formula shape for about six months. What had been growing was not only the cost — it was the amount of time I spent not knowing what was happening.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.