The first time I hit a Google Sheets API rate limit, the dashboard was barely getting traffic. That was what made it confusing. I had assumed the 300-read-per-minute project quota was plenty, but my backend was using one service account, which meant every request also counted against the much smaller 60-read-per-minute per-user limit.
The error arrived as a 429 RESOURCE_EXHAUSTED, then vanished a minute later, then came back when traffic picked up again. It looked intermittent until I stopped treating it as a Google outage and started counting API calls. One page view was one sheet read. Ten open tabs polling every five seconds were 120 reads a minute before a real visitor arrived.
Google Sheets is a perfectly reasonable source of truth for a small, read-heavy app. It is not an API you should call once for every person looking at a page. The difference matters because the durable fix is not better retries. It is reducing how often your app asks Google for the same data.
The two limits that matter
Google applies quotas per minute, and refills them every minute. At the time of writing, the published limits are 300 read requests per minute per Cloud project and 60 read requests per minute per user per project. Writes have the same limits.
| Quota | Limit | What it means |
|---|---|---|
| Read requests | 300/min | Shared by the Cloud project |
| Read requests | 60/min | Per user within that project |
| Write requests | 300/min | Shared by the Cloud project |
| Write requests | 60/min | Per user within that project |
The 60 limit is the surprise. If a server uses one service account to read every sheet, Google attributes all of those calls to one identity. Sixty reads in a minute is easy to reach during a launch, from a polling dashboard, or when an AI agent explores the data with several queries before it answers.
Google documents the limits and the recovery behavior in its Sheets API usage limits. Standard use is currently free, although Google says quota overages are planned to incur Cloud billing charges later in 2026. That is another reason to treat the quota as an architectural boundary rather than something to repeatedly run into.
What a quota error actually looks like
The HTTP status is 429; the useful detail is in the response body. It tells you which bucket is exhausted:
{
"error": {
"code": 429,
"message": "Quota exceeded for quota metric 'Read requests' and limit 'Read requests per minute per user' of service 'sheets.googleapis.com'",
"status": "RESOURCE_EXHAUSTED"
}
}
If the message names the per-user quota, adding more visitors will not explain why it happens. Your application is concentrating all reads through one identity. If it names the per-project quota, several users or services are collectively using the same project budget. The status code is identical, so logging the response body is worth the extra line.
Back off correctly when a 429 happens
Retries do have a place. Google recommends truncated exponential backoff with jitter, which spaces clients out instead of letting them all retry on the same second. Here is a small Node 18+ helper for a direct Sheets API call:
async function fetchWithBackoff(url, options, maxRetries = 5) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status !== 429) {
return response;
}
if (attempt === maxRetries) {
throw new Error("Google Sheets quota still exhausted after retries");
}
const baseDelayMs = Math.min(2 ** attempt * 1000, 32_000);
const jitterMs = Math.floor(Math.random() * 1000);
await new Promise((resolve) => setTimeout(resolve, baseDelayMs + jitterMs));
}
}
const spreadsheetId = process.env.SPREADSHEET_ID;
const accessToken = process.env.GOOGLE_ACCESS_TOKEN;
const response = await fetchWithBackoff(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/Products!A:D`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
console.log(await response.json());
This prevents a retry storm and gives a short traffic spike time to pass. It does not increase the quota. If your application needs 100 reads a minute from a single service account, a retry loop turns the excess 40 reads into slower requests. It does not make them valid requests.
Cache the sheet, not every visitor
The change that fixes the underlying problem is to fetch the sheet once, store its rows for a short time, and serve every matching request from that copy. With a 60-second cache TTL, 1,000 visitors can become one upstream Sheets API read instead of 1,000.
Here is the smallest useful shape in a single Node process. It is enough to demonstrate the boundary: the cache belongs around the Google call, not around individual browser requests.
const cache = new Map();
const cacheTtlMs = 60_000;
async function getProducts() {
const existing = cache.get("products");
if (existing && Date.now() - existing.fetchedAt < cacheTtlMs) {
return existing.rows;
}
const response = await fetchWithBackoff(
`https://sheets.googleapis.com/v4/spreadsheets/${process.env.SPREADSHEET_ID}/values/Products!A:D`,
{ headers: { Authorization: `Bearer ${process.env.GOOGLE_ACCESS_TOKEN}` } },
);
if (!response.ok) {
throw new Error(`Sheets request failed: ${response.status}`);
}
const { values = [] } = await response.json();
const [headers, ...rows] = values;
const products = rows.map((row) => Object.fromEntries(
headers.map((header, index) => [header, row[index] ?? null]),
));
cache.set("products", { rows: products, fetchedAt: Date.now() });
return products;
}
Do not mistake that Map for a production cache. It disappears on deploy and every server instance gets its own copy. Use a shared cache such as Redis, a platform cache, or an edge cache when you run more than one instance. The important part is that the TTL is tied to the data's freshness requirement, not to your traffic level.
If you are fetching several ranges directly, use spreadsheets.values.batchGet as well. Google counts each batch request as one API request, including its subrequests. Batching trims waste. Caching changes the order of magnitude.
Where a hosted endpoint earns its keep
I built PasteSheet because I kept seeing this infrastructure work repeated for sheets that only needed to be read. It publishes a shared Google Sheet as a cached JSON endpoint, so filtering, sorting, and pagination happen against the cached rows rather than creating another call to Google. A cached Google Sheets API gives the same shape to an app regardless of whether one person or a thousand people are reading it.
That is not the right answer for every sheet. If you need private source data, per-user Google permissions, or writes, use Google's OAuth flow and build the cache in the application that owns those rules. PasteSheet requires the source sheet to be shared for viewing and is read-only by design. It is a good fit for product catalogs, public listings, FAQ content, and configuration that needs to stay editable by non-developers.
For the read-heavy case, though, caching is not an optimization to add after the 429. It is the piece that turns a spreadsheet into a reliable API. The full Google Sheets REST API guide walks through the endpoint approach, and the companion guide covers what to do when you have already exceeded the quota.
I build PasteSheet: paste a Google Sheet URL and get a cached JSON API plus a read-only MCP server your AI agent can query. Free tier, no credit card, no Google Cloud project. The part I am curious about is which cache layer you reached for first, because that choice tends to say more about an app's traffic pattern than the spreadsheet does.
Top comments (0)