Workaround for Apps Script UrlFetchApp “Bandwidth quota exceeded” errors using Cloud Run

We are currently experiencing intermittent UrlFetchApp errors in Apps Script:

Exception: Bandwidth quota exceeded: <url>. Try reducing the rate of data transfer.

This started suddenly around April 2026 and matches the public issue reported here:

https://issuetracker.google.com/issues/505172128

The problem is that the error happens even with low usage, small payloads, and previously stable scripts. In our case, this makes Apps Script web apps unreliable because any server-side API call using UrlFetchApp.fetch() can randomly fail.

This article shows the workaround I used: move the API call to Cloud Run and call Cloud Run directly from the Apps Script frontend using client-side fetch().

We will replace this API call:

const response = Sheets.Spreadsheets.Values.get(ssId, sheetName);

With:

Apps Script Web App
↓ client-side fetch()
Cloud Run service
↓ Google Sheets API
Spreadsheet data

Apps Script only serves the HTML page. The browser calls Cloud Run. Cloud Run reads the spreadsheet using the Sheets API and returns JSON.

1. Create the Cloud Run service

Create a local folder with:

package.json
server.js

package.json

{
"name": "sheets-api-proxy",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.3",
"googleapis": "^144.0.0"
}
}

server.js

import express from "express";
import { google } from "googleapis";const app = express();app.use(express.json());const PORT = process.env.PORT || 8080;
const SPREADSHEET_ID = process.env.SPREADSHEET_ID;/**
 * Adds CORS headers for Apps Script web app frontend calls.
 *
 * @param {import("express").Request} req The Express request.
 * @param {import("express").Response} res The Express response.
 * @param {import("express").NextFunction} next The next Express middleware.
 * @returns {void}
 */
function corsMiddleware(req, res, next) {
  const origin = req.headers.origin || "";  const isAllowedOrigin =
    origin === "https://script.google.com" ||
    origin.endsWith(".googleusercontent.com");  if (isAllowedOrigin) {
    res.setHeader("Access-Control-Allow-Origin", origin);
  }  res.setHeader("Vary", "Origin");
  res.setHeader("Access-Control-Allow-Methods", "GET,OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization");  if (req.method === "OPTIONS") {
    res.status(204).send("");
    return;
  }  next();
}app.use(corsMiddleware);/**
 * Creates an authenticated Google Sheets API client.
 *
 * @returns {Promise<import("googleapis").sheets_v4.Sheets>} The authenticated Sheets API client.
 */
async function getSheetsClient() {
  const auth = new google.auth.GoogleAuth({
    scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"]
  });  const authClient = await auth.getClient();  return google.sheets({
    version: "v4",
    auth: authClient
  });
}/**
 * Reads values from a Google Sheet range.
 *
 * @param {string} range The A1 notation range.
 * @returns {Promise<Array<Array<string|number|boolean>>>} The sheet values.
 */
async function readSheetValues(range) {
  const sheets = await getSheetsClient();  const response = await sheets.spreadsheets.values.get({
    spreadsheetId: SPREADSHEET_ID,
    range
  });  return response.data.values || [];
}/**
 * Converts a 2D array into objects using the first row as headers.
 *
 * @param {Array<Array<string|number|boolean>>} values The sheet values.
 * @returns {Array<Object>} The rows as objects.
 */
function valuesToObjects(values) {
  if (!values.length) {
    return [];
  }  const [headers, ...rows] = values;  return rows.map(row => {
    return headers.reduce((obj, header, index) => {
      obj[String(header)] = row[index] ?? "";
      return obj;
    }, {});
  });
}app.get("/api/sheet", async (req, res) => {
  try {
    if (!SPREADSHEET_ID) {
      res.status(500).json({
        error: "Missing SPREADSHEET_ID environment variable"
      });
      return;
    }    const range = req.query.range || "Sheet1!A1:Z";
    const values = await readSheetValues(range);
    const rows = valuesToObjects(values);    res.json({
      range,
      rowCount: rows.length,
      rows
    });
  } catch (error) {
    console.error("SHEETS_API_READ_FAILED", {
      message: error.message,
      stack: error.stack
    });    res.status(500).json({
      error: "Failed to read spreadsheet data",
      message: error.message
    });
  }
});
app.listen(PORT, () => {
  console.log(`Sheets proxy listening on port ${PORT}`);
}

2. Deploy to Cloud Run

From VS Code, login to Google Cloud, link the porject (has to be linked to a billing account in order to deploy on Cloud Run), and set the region.

gcloud auth login
gcloud config set project YOUR_PROJECT_ID
gcloud config set run/region europe-west1
Deploy:
gcloud run deploy sheets-api-proxy `
  --source . `
  --region europe-west1 `
  --allow-unauthenticated `
  --set-env-vars="SPREADSHEET_ID=YOUR_SPREADSHEET_ID"

After deployment, Cloud Run gives you a URL like:

https://sheets-api-proxy-xxxx.europe-west1.run.app

3. Fix the common IAM issue

If deployment fails with something like:

Build failed because the default service account is missing required IAM permissions

check which service account is used:

gcloud builds get-default-service-account

Then grant the required role:

gcloud projects add-iam-policy-binding YOUR_PROJECT_ID `
  --member="serviceAccount:YOUR_SERVICE_ACCOUNT" `
  --role="roles/run.builder"

4. Share the Google Sheet with Cloud Run

Cloud Run does not run as your Gmail account. It runs as a service account. Find the runtime service account:

gcloud run services describe sheets-api-proxy `
  --region europe-west1 `
  --format="value(spec.template.spec.serviceAccountName)"

If the result is empty, it is probably using the Compute Engine default service account:

PROJECT_NUMBER-compute@developer.gserviceaccount.com

Share the Google Sheet with that email and give it Viewer access.

5. Create the Apps Script web app

In Apps Script, create:

/**
 * Serves the web app HTML.
 *
 * @param {GoogleAppsScript.Events.DoGet} e The GET event object.
 * @returns {GoogleAppsScript.HTML.HtmlOutput} The HTML output.
 */
function doGet(e) {
  return HtmlService
    .createHtmlOutputFromFile("Index")
    .setTitle("Cloud Run Sheets Reader");
}

And Index.html

<!DOCTYPE html>
<html>
<head>
  <base target="_top">

  <style>
    body {
      font-family: Arial, sans-serif;
      padding: 24px;
      background: #f7f7f7;
      color: #222;
    }

    button {
      padding: 10px 16px;
      border: none;
      border-radius: 8px;
      background: #1a73e8;
      color: white;
      cursor: pointer;
      font-size: 14px;
    }

    button:disabled {
      opacity: 0.6;
      cursor: not-allowed;
    }

    #status {
      margin: 16px 0;
      color: #555;
    }

    table {
      width: 100%;
      border-collapse: collapse;
      background: white;
    }

    th,
    td {
      border-bottom: 1px solid #ddd;
      padding: 10px;
      text-align: left;
    }

    th {
      background: #f1f3f4;
    }
  </style>
</head>

<body>
  <h2>Sheet data from Cloud Run</h2>

  <button id="load-btn" onclick="loadSheetData()">Load data</button>

  <div id="status"></div>
  <div id="table-container"></div>

  <script>
    const CLOUD_RUN_URL = "https://YOUR_CLOUD_RUN_URL";
    const SHEET_RANGE = "Sheet1!A1:Z";

    /**
     * Reads spreadsheet data from Cloud Run and displays it as a table.
     *
     * @returns {Promise<void>}
     */
    async function loadSheetData() {
      const button = document.getElementById("load-btn");
      const status = document.getElementById("status");
      const container = document.getElementById("table-container");

      try {
        button.disabled = true;
        status.textContent = "Loading...";
        container.innerHTML = "";

        const range = encodeURIComponent(SHEET_RANGE);
        const response = await fetch(`${CLOUD_RUN_URL}/api/sheet?range=${range}`);

        if (!response.ok) {
          throw new Error(`Cloud Run request failed with status ${response.status}`);
        }

        const data = await response.json();

        renderTable(data.rows || []);
        status.textContent = `${data.rowCount || 0} rows loaded`;
      } catch (error) {
        console.error(error);
        status.textContent = error.message;
      } finally {
        button.disabled = false;
      }
    }

    /**
     * Renders rows as an HTML table.
     *
     * @param {Array<Object>} rows The rows to render.
     * @returns {void}
     */
    function renderTable(rows) {
      const container = document.getElementById("table-container");

      if (!rows.length) {
        container.innerHTML = "<p>No data found.</p>";
        return;
      }

      const headers = Object.keys(rows[0]);

      const thead = headers
        .map(header => `<th>${escapeHtml(header)}</th>`)
        .join("");

      const tbody = rows
        .map(row => {
          const cells = headers
            .map(header => `<td>${escapeHtml(row[header])}</td>`)
            .join("");

          return `<tr>${cells}</tr>`;
        })
        .join("");

      container.innerHTML = `
        <table>
          <thead>
            <tr>${thead}</tr>
          </thead>
          <tbody>
            ${tbody}
          </tbody>
        </table>
      `;
    }

    /**
 * Escapes a value before injecting it into HTML.
 *
 * @param {*} value The value to escape.
 * @returns {string} The escaped HTML string.
 */
function escapeHtml(value) {
  return String(value ?? "")
    .replaceAll("&", "&")
    .replaceAll("<", "<")
    .replaceAll(">", ">")
    .replaceAll('"', """)
    .replaceAll("'", "'");
}
  </script>
</body>
</html>

Deploy the Apps Script project as a web app. When you click the button, the browser calls Cloud Run directly.

No UrlFetchApp is used in this call path.

6. About CORS

Apps Script web apps are often served from a googleusercontent.com origin, not only from script.google.com.

That is why the Cloud Run code allows both:

origin === "https://script.google.com" ||
origin.endsWith(".googleusercontent.com")

7. Adding authentication

The demo above uses: –allow-unauthenticated

This is fine for testing, but not for production.

CORS is not authentication. It only controls browser access. Someone can still call your Cloud Run URL from another backend, Postman, or curl.

To reproduce the usual Apps Script behavior, Apps Script can generate a short-lived signed token for the current user. The browser sends that token to Cloud Run, and Cloud Run verifies it before returning data.

Apps Script server-side:

const CLOUD_RUN_SHARED_SECRET = "CHANGE_ME_LONG_RANDOM_SECRET";

/**
 * Creates a short-lived token for Cloud Run.
 *
 * @returns {string} The signed token.
 */
function getCloudRunToken() {
  const now = Math.floor(Date.now() / 1000);

  const payload = {
    iat: now,
    exp: now + 300,
    userEmail: Session.getActiveUser().getEmail()
  };

  const payloadText = JSON.stringify(payload);
  const payloadBase64 = Utilities.base64EncodeWebSafe(payloadText);
  const signature = Utilities.computeHmacSha256Signature(payloadBase64, CLOUD_RUN_SHARED_SECRET);
  const signatureBase64 = Utilities.base64EncodeWebSafe(signature);

  return `${payloadBase64}.${signatureBase64}`;
}

Frontend:

/**
 * Gets a short-lived Cloud Run token from Apps Script.
 *
 * @returns {Promise<string>} The signed token.
 */
function getCloudRunTokenFromServer() {
  return new Promise((resolve, reject) => {
    google.script.run
      .withSuccessHandler(resolve)
      .withFailureHandler(reject)
      .getCloudRunToken();
  });
}

Then send it to Cloud Run:

const token = await getCloudRunTokenFromServer();

const response = await fetch(`${CLOUD_RUN_URL}/api/sheet?range=${range}`, {
  method: "GET",
  headers: {
    Authorization: `Bearer ${token}`
  }
});

The token does transit through the client, so it must be short-lived.

For a more complete production app, Firebase Auth or Identity Platform is a better long-term option: the frontend signs in the user, sends an ID token to Cloud Run, and Cloud Run verifies it server-side.

Conclusion

This article only shows one possible workaround while this bug is happening. It probably does not solve every case, but it allowed me to save some important workflows on my side.

If you have another idea or a better workaround, I would be interested to hear it.

onEdit vs onChange in Google Sheets (Apps Script)

When automating Google Sheets with Apps Script, two triggers are often confused: onEdit and onChange. They behave differently, and this short guide summarizes what actually triggers each one.

What triggers each event

Action in the spreadsheetonEditonChange
Edit a cell value✔️✔️
Insert a row or column✔️
Delete a row or column✔️
Change background color or font✔️
Create or delete a sheet✔️
Move a column or row with data✔️✔️
Move a column or row without data✔️

Key idea

  • onEdit reacts to data edits inside cells.
  • onChange reacts to structural changes in the spreadsheet.

This means that formatting changes, sheet structure modifications, or column insertions will not trigger onEdit, but will trigger onChange.

Comparing the event objects

Another major difference is the object passed to the function.

Example: onEdit(e)

onEdit provides detailed information about the edited range.

{
  "authMode":"FULL",
  "range":{
    "columnEnd":14,
    "columnStart":14,
    "rowEnd":1000,
    "rowStart":1
  },
  "source":{},
  "triggerUid":"2212109739582554112",
  "user":{
    "email":"lidia.calicia@gmail.com",
    "nickname":"lidia.calicia"
  }
}

Important point:
The range property tells you exactly which cells were edited.

Example: onChange(e)

onChange focuses on what type of change happened, not where.

{
  "authMode":"FULL",
  "changeType":"INSERT_COLUMN",
  "source":{},
  "triggerUid":"4991534397111926784",
  "user":{
    "email":"lidia.calicia@gmail.com",
    "nickname":"lidia.calicia"
  }
}

Important point:
Instead of a range, you receive a changeType, such as:

  • INSERT_ROW
  • INSERT_COLUMN
  • REMOVE_ROW
  • REMOVE_COLUMN
  • FORMAT
  • OTHER

Quick rule of thumb

Use:

  • onEdit when you need to react to cell value changes.
  • onChange when you need to react to spreadsheet structure or formatting changes.

Understanding this distinction avoids many automation bugs in Apps Script projects.

Handle Multiple Google Form Submissions in One Spreadsheet with a Single Apps Script Trigger

When working with Google Forms + Google Sheets, a common setup is to connect several forms to the same spreadsheet. Each form creates its own response sheet automatically.

This works well at first. But as soon as you want to automate workflows with Google Apps Script, things become messy.

Typical situations:

  • You have multiple forms for different processes (client onboarding, support requests, event registrations, etc.)
  • Each form writes responses into a different sheet of the same spreadsheet
  • You want to trigger different automation logic depending on the form

The issue is that the Form Submit trigger in Google Sheets does not allow you to specify a different function depending on which form submitted the response.

The approach I use is to create one global trigger function that runs whenever a form submission occurs. This function then routes the event to the appropriate handler, based on the sheet that received the response.

A Single Dispatcher Trigger

The e object returned by the trigger provides the context of the event. From it, we can retrieve the range that triggered the execution, then access the sheet, and finally obtain the sheet name using function chaining.

const sheetName = e.range.getSheet().getName();

From there, we can decide which process should handle the submission.

Here is an example which can be easily

function Main_FormDispatcher(e) {

  // Get sheet name to retrieve what form was submitted
  const sheetName = e.range.getSheet().getName();

  Logger.log(`Form response received from sheet: ${sheetName}`);

  // Dispatch event
  if (sheetName === "Client Intake Responses") {
    processClientIntake(e);
  } else if (sheetName === "Bug Report Responses") {
    processBugReport(e);
  } else if (sheetName === "Newsletter Signup Responses") {
    processNewsletterSignup(e);
  } else {
    Logger.log(`Unknown form sheet detected: ${sheetName}`);
  }
}

If you regularly build Google Workspace automations, structuring your scripts around dispatcher functions like this one will save you a lot of time and complexity later.

OAuth with Google Apps Script

One of the biggest strengths of Google Apps Script is how easily it interacts with other Google products. Reading a Google Sheet, creating a Drive file, or updating a document requires almost no setup. You don’t have to think about authentication, token management, or security flows. Google automatically prompts the required scopes and takes care of the authorization for you.

This convenience can make OAuth feel invisible, even unnecessary. Until the day your script needs to talk to something outside Google.

This article takes a step back to explain what OAuth and OAuth 2.0 actually are, why Apps Script usually hides them from you, and what changes when you integrate external services. We’ll also look at how the OAuth2 library fits into the picture when built-in authorization is no longer enough.

OAuth in simple terms

OAuth 2.0 allows an application to access a user’s data without ever handling their password. Instead of sharing credentials, the user approves a limited set of permissions, and the service issues a token representing what the application is allowed to do and for how long.

In an OAuth flow, the user authenticates directly with the service that owns the data. Once consent is given, the service provides a token to the application. From that point on, the application uses this token to make requests, and the service validates it before allowing access. The application never sees the user’s credentials.

OAuth 2.0 is an evolution of the original OAuth concept. It formalizes this permission model using short-lived access tokens and refresh tokens, making integrations more secure, easier to revoke, and better suited to modern applications. Today, when we talk about OAuth, we almost always mean OAuth 2.0.

OAuth in Apps Script

When an Apps Script project interacts only with Google products, OAuth is handled entirely by the platform. Permissions are inferred from the services you use, Google displays the consent screen, and tokens are stored and refreshed automatically. In this context, we don’t really htink about OAuth at all.

You can see the project scopes in the information panel

This changes as soon as your script needs to communicate with a service outside Google.

Most external APIs rely on OAuth 2.0, which means your script must explicitly request user consent, obtain access tokens, refresh them when they expire, and attach them to API requests. At this point, OAuth is no longer automatic and becomes part of your implementation.

To make this manageable, Apps Script relies on a community OAuth2 library. The library implements the OAuth 2.0 protocol for you, handling redirects, callbacks, token exchanges, storage, and refresh logic. You configure it once, then reuse it whenever your script needs to authenticate with the external API.

How the OAuth2 library works in Apps Script

The OAuth2 library revolves around the idea of a service. A service represents a configured OAuth connection to an external provider. It contains everything needed to authenticate with that API:

  • Authorization URL
    This is the endpoint where the user is redirected to grant consent. It displays the provider’s login and consent screen and is the entry point of the OAuth flow.
  • Token endpoint
    This endpoint is used after consent is granted. It exchanges the authorization code for an access token (and usually a refresh token), and is also used later to refresh expired access tokens.
  • Client credentials (Client ID and Client Secret)
    These identify your Apps Script project to the OAuth provider. They are issued when you register your application and allow the provider to trust who is requesting tokens.
  • Requested scopes
    Scopes define what the application is allowed to do. They limit access to specific actions or resources and are shown to the user on the consent screen.
  • Callback function
    This is the function Apps Script calls after the provider redirects back to your project. It completes the OAuth flow by handing the response to the library, which then retrieves and stores the tokens.
  • Property store
    This defines where access and refresh tokens are persisted. The service uses PropertiesService to store tokens so the user does not need to re-authorize on every execution.

In short, the service is the OAuth contract between Apps Script and an external provider. Once configured, your code no longer deals with OAuth mechanics directly, it simply asks the service for a valid access token and makes API requests.

A short example

The first step is to add the OAuth2 library to your Apps Script project. This can be done by searching for the library using its script ID:

1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF

Now, let’s see a very simple example of

/**
 * Creates and returns the OAuth2 service.
 * @returns {OAuth2.Service}
 */
function getService_() {
  return OAuth2.createService('MyApi')

    // Authorization and token endpoints provided by the API
    .setAuthorizationBaseUrl('https://example.com/oauth/authorize')
    .setTokenUrl('https://example.com/oauth/token')

    // Credentials issued by the provider
    .setClientId('YOUR_CLIENT_ID')
    .setClientSecret('YOUR_CLIENT_SECRET')

    // Callback function called after user consent
    .setCallbackFunction('authCallback')

    // Where tokens are stored
    .setPropertyStore(PropertiesService.getUserProperties())

    // Permissions requested
    .setScope('read write');
}

Apps Script cannot redirect automatically, so you typically show the authorization URL as a link.

/**
 * Shows an authorization link if access is missing.
 */
function authorize() {
  const service = getService_();

  if (!service.hasAccess()) {
    const url = service.getAuthorizationUrl();
    Logger.log('Authorize this app by visiting: %s', url);
  }
}

This function is called by Apps Script when the provider redirects back after consent.

/**
 * OAuth callback handler.
 * @param {Object} request
 * @returns {HtmlOutput}
 */
function authCallback(request) {
  const service = getService_();
  const authorized = service.handleCallback(request);

  return HtmlService.createHtmlOutput(
    authorized ? 'Authorization successful.' : 'Authorization denied.'
  );
}

Once authorized, the library automatically returns a valid token and refreshes it if needed.

/**
 * Makes an authenticated API request.
 */
function callApi() {
  const service = getService_();

  if (!service.hasAccess()) {
    throw new Error('Service not authorized');
  }

  const response = UrlFetchApp.fetch('https://example.com/api/data', {
    headers: {
      Authorization: 'Bearer ' + service.getAccessToken()
    }
  });

  Logger.log(response.getContentText());
}

Conclusion

OAuth can feel complex at first, but in practice it follows a simple idea: allowing an application to act on behalf of a user without ever handling their credentials. In Google Apps Script, this complexity is often hidden when working with Google services, which is why OAuth can go unnoticed for a long time.

As soon as you integrate an external API, OAuth becomes explicit. The OAuth2 library provides a structured and reliable way to handle authorization, token storage, and refresh logic, without forcing you to reimplement the OAuth 2.0 protocol yourself. Once the service is configured, the rest of your code can focus purely on business logic and API interactions.

To go further, see the follow-up article with a complete OAuth2 implementation in Apps Script using Xero.