Your first Gemini integration feels like magic, but scaling it from one task to thousands is the real challenge. Learn how to build a robust batch processing system for your AI workflows in Google Workspace.
So, you’ve built your first Gemini integration in AC2F Streamline Your Google Drive Workflow. Maybe it’s a custom function in Sheets that analyzes customer feedback in a cell, or a Google Docs add-on that summarizes meeting notes. The first time you see it work, it feels like magic. You’ve successfully connected the collaborative power of Workspace with the generative intelligence of a state-of-the-art AI.
The natural next step is to think bigger. Instead of summarizing one document, why not summarize a hundred? Instead of categorizing ten rows of feedback, why not categorize ten thousand? You modify your script to loop through all the documents in a folder or all the rows on a sheet, making a call to the Gemini API for each one. You run it, and for a small batch, it works flawlessly.
Then, you unleash it on your real dataset. And that’s when the magic stops and the frustration begins.
If you’ve worked with [AI Powered Cover Letter [Automated Job Creation in Real Time Jobber and Google Sheets Integration from Gmail](https://votuduc.com/Automated-Job-Creation-in-Jobber-from-Gmail-p115606) Engine](https://votuduc.com/AI-Powered-Cover-Letter-Automated Quote Generation and Delivery System for Jobber-Engine-p111092) for any length of time, you’ve likely met its most notorious gatekeeper: the execution time limit. For a standard Gmail account, a script can run for a maximum of 6 minutes. For Automated Client Onboarding with Google Forms and Google Drive. accounts, that limit is extended to 30 minutes.
At first glance, 30 minutes seems generous. But when you’re making external API calls in a loop, that clock ticks down with alarming speed. Each call to the Gemini API involves:
Packaging the request data.
Sending it over the network to Google’s servers.
Waiting for the Gemini model to process the prompt and generate a response.
Receiving that response back over the network.
Even a fast API call might take 2-5 seconds. If you need to process 500 rows in a Google Sheet, and each call averages just 4 seconds, you’re looking at a total execution time of 2,000 seconds, or over 33 minutes. You’ve just blown past the hard limit for a Workspace account, and your script will unceremoniously terminate with the dreaded Exception: Service invoked too many times for one day: urlfetch. or, more commonly, the simple Execution time exceeded error. This isn’t a bug; it’s a fundamental constraint of the Apps Script platform, which is designed for short-lived, transactional operations, not long-running batch jobs.
The root of the problem lies in the synchronous, or “blocking,” nature of our initial approach. The script executes in a simple, linear fashion:
For each row in the Sheet:
1. Call the Gemini API with the row's data.
2. WAIT for the API to respond.
3. Write the response back to the Sheet.
4. Move to the next row.
This model is easy to understand and implement, but it has critical flaws when scaled:
Compounding Latency: The script spends most of its time just waiting. Each pause, while small on its own, is multiplied by the number of items you’re processing. The total execution time becomes a direct function of (number of items) * (average API latency), which is a recipe for hitting platform limits.
Fragility: What happens if a single API call fails due to a temporary network blip or a malformed request for row #257 out of 1000? The entire script halts. The first 256 rows are processed, but the remaining 743 are left untouched. You have no easy way to resume the job from where it left off, and implementing robust error handling and retry logic within a single, long-running script is complex and often unreliable.
Poor User Experience: If the script is triggered from a custom menu in a Sheet or Doc, the user’s interface is frozen until the script either completes or times out. They can’t do anything else. Asking a user to keep a browser tab open and unresponsive for 20 minutes is simply not a viable solution.
To overcome these limitations, we need to stop thinking of this as a single, monolithic task. We need an architectural shift away from a synchronous script and towards an asynchronous task queue pattern.
The core concept is simple but powerful: decouple the request for work from the execution of the work.
Instead of having Apps Script perform the entire job, we give it a much simpler, faster responsibility: breaking the large job into hundreds of small, independent tasks and adding them to a dedicated to-do list. This “to-do list” is a task queue.
Here’s how the new architecture works at a high level:
The Trigger (Apps Script): A user clicks a button in Google Sheets. The Apps Script rapidly iterates through the 10,000 rows. For each row, it doesn’t call the Gemini API. Instead, it creates a small “task” message (e.g., a JSON payload containing the row number and the text to be processed) and sends it to a task queue service, like Firebase Cloud Tasks. This entire process takes seconds, not minutes, because it’s not waiting for any AI processing.
The Queue (Cloud Tasks): The queue acts as a durable, reliable buffer. It holds all 10,000 tasks, waiting to be processed. It can manage the rate of processing, automatically retry tasks that fail, and ensure that every task is eventually handled.
The Worker (Cloud Function): A separate, serverless function (like a Google Cloud Function) is configured as the “worker.” Its sole job is to pull one task at a time from the queue, make the actual call to the Gemini API, process the result, and then update the Google Sheet directly using an API.
This asynchronous, event-driven model completely changes the game. The Apps Script is done in a flash, the user’s UI is never blocked, and the actual AI processing happens reliably and scalably on a backend designed for exactly this kind of heavy lifting.
To build a scalable and resilient system for processing Gemini jobs, we don’t need a complex stack of virtual machines, load balancers, and dedicated message brokers. Instead, we can assemble a powerful, serverless orchestration layer using the tightly integrated services within the Google Cloud and Firebase ecosystem. This architecture is event-driven by nature, meaning actions are triggered automatically in response to changes in our system’s state, creating a highly efficient and cost-effective pipeline.
At its heart, this system treats our database, Firestore, not just as a place to store data, but as the central nervous system for our entire task queue.
Our architecture is deceptively simple, relying on three key pillars that work in perfect harmony.
gemini_tasks, where each document represents a single, atomic job for the Gemini API. A typical task document is a simple JSON object that tracks the entire lifecycle of the job. It holds the input, monitors the status, and eventually stores the output. A document might look like this:
{
"prompt": "Write a short, futuristic poem about coffee.",
"status": "pending", // pending -> processing -> completed | error
"createdAt": "2023-10-27T10:00:00Z",
"result": null,
"errorDetails": null
}
Cloud Functions: These are our serverless, on-demand workers. Think of them as the compute engine that springs into action when a new task arrives. We will deploy a function that is configured with a Firestore trigger. Specifically, it will listen for the onDocumentCreate event in our gemini_tasks collection. When a new document is added, the function automatically executes, receiving the document’s data as its payload. This function contains the business logic to call the Gemini API and update the task document with the result.
The Gemini API: This is the powerful AI engine we are orchestrating. It’s the external service that performs the heavy lifting—generating text, analyzing images, or whatever we’ve tasked it with. Our Cloud Function acts as a secure, server-side intermediary that authenticates with and sends requests to the Gemini API, passing along the prompt from the Firestore document.
The elegance of this architecture lies in its straightforward, observable data flow. Let’s walk through the lifecycle of a single task.
Task Creation: A client application (a web front-end, a mobile app, or even another backend service) initiates a job by writing a new document to the /gemini_tasks collection in Firestore. The document’s initial status is set to pending.
Function Trigger: The moment this document is created, the Firestore onCreate trigger fires, invoking our dedicated Cloud Function. This is an instantaneous, event-driven handoff—no polling or manual checks required.
Acknowledge and Process: As its very first step, the Cloud Function performs a crucial state update. It sets the task document’s status to processing. This acts as a lock, preventing any other function invocations from accidentally processing the same job and providing real-time feedback to any listening clients that the work has begun.
Engage the AI: The function extracts the prompt from the document data and makes an authenticated, asynchronous call to the Gemini API.
Record the Outcome: Once the Gemini API responds, the function handles both success and failure scenarios:
On Success: The function receives the generated content from Gemini. It then performs a final update to the Firestore document, setting the status to completed, writing the AI-generated content into the result field, and adding a completedAt timestamp.
On Failure: If the API call fails or an internal error occurs, the function’s catch block executes. It updates the document’s status to error and populates the errorDetails field with a descriptive message or error object. This preserves the failure context for debugging and potential retries.
While you could use dedicated services like RabbitMQ or Google Cloud Tasks, using Firestore as the backbone of our queue offers a unique set of advantages perfectly suited for a serverless, event-driven model.
Natively Event-Driven: The tight, trigger-based integration between Firestore and Cloud Functions is the system’s superpower. The database itself emits the events that drive the compute layer. This eliminates the complexity of managing a separate message bus and the boilerplate code needed to connect services.
Built-in State Management and Auditing: Unlike a traditional message queue where a message is consumed and deleted, a Firestore document persists. This gives us a complete, queryable audit trail of every job—its inputs, outputs, timestamps, and any errors—for free. Retrying a failed job is as simple as updating its status back to pending.
Inherent Scalability and Reliability: Firestore is a massively scalable, multi-region, serverless database. It can handle millions of concurrent writes, meaning your system can accept a huge influx of tasks without falling over. This scalability is mirrored by Cloud Functions, which will automatically scale the number of instances to meet the demand of incoming tasks.
Seamless Real-Time Feedback: Because Firestore is a real-time database at its core, providing front-end clients with live progress updates is trivial. There’s no need to build a separate WebSocket server or polling mechanism; the Firebase SDK handles the real-time data synchronization for you, dramatically improving the user experience.
Before we can process any jobs with Gemini, we need a place to put them. A task queue is the perfect architectural pattern for managing asynchronous, long-running operations. It decouples the initial request (e.g., “summarize this text”) from the actual execution, providing resilience and scalability. For our serverless stack, Firestore is an excellent choice to implement this queue. Its real-time capabilities and robust SDKs make it simple to add tasks and for our backend workers to listen for new jobs.
First, let’s define the structure of our queue. In Firestore, this translates to a new collection. We’ll call it gemini-tasks. Each document within this collection will represent a single, atomic job for the Gemini API to perform.
A well-designed document schema is crucial for a maintainable system. It needs to contain all the information necessary to execute the job, track its progress, and store the final result. Here’s a robust schema to get us started:
{
"prompt": "The full text or prompt to be sent to the Gemini API...",
"status": "pending", // Can be: pending, processing, completed, error
"createdAt": "Timestamp", // Firestore server timestamp
"updatedAt": "Timestamp", // Firestore server timestamp
"result": {
"summary": "The generated summary will be stored here.",
"tokenCount": 150
},
"error": "Any error messages will be stored here if the job fails."
}
Let’s break down these fields:
prompt (string): This field holds the primary input for our Gemini job. For our summarization use case, this would be the large block of text we want to condense.
status (string): This is the heart of our queue’s state management. It tracks the lifecycle of the task. We’ll dive deeper into the specific statuses in the next section.
createdAt (Timestamp): A server-generated timestamp that marks when the task was first created. This is invaluable for monitoring, debugging, and calculating queue times.
updatedAt (Timestamp): Another server-generated timestamp that updates whenever the document is modified. This helps us see when the task was last touched, for instance, when it moved from pending to processing.
result (map): An object to store the successful output from the Gemini API. Structuring it as a map allows us to store multiple pieces of information, like the generated text and metadata (e.g., token usage). It should only be populated upon successful completion.
error (string): If the job fails at any point, this field will store a descriptive error message. This is critical for debugging and potentially implementing retry logic.
The status field is the engine of our queue. It allows our different system components (the client that enqueues the task and the worker that processes it) to coordinate without direct communication. Every task will transition through a clear lifecycle defined by these four statuses:
pending: This is the initial state for every new task. When a document is created in the gemini-tasks collection, its status is set to pending. This signifies that the job has been accepted by the system and is waiting for a worker to become available and claim it. Our backend workers will be configured to listen specifically for documents in this state.
processing: As soon as a worker picks up a pending task, its first action is to update the task’s status to processing. This is a critical step that acts as a “lock.” It prevents other workers from accidentally picking up and processing the same job, ensuring each task is executed exactly once. This status indicates that the job is actively being worked on—the call to the Gemini API is in flight.
completed: Upon a successful response from the Gemini API, the worker updates the status to completed. At the same time, it populates the result field with the payload from Gemini. This is the final, successful state for a task. The client application can then listen for this status change to retrieve the result and display it to the user.
error: If anything goes wrong during processing—whether it’s an invalid prompt, an API key issue, a timeout, or a Gemini API error—the worker will catch the exception. It will then update the status to error and populate the error field with a detailed message. This state is essential for alerting and diagnostics. You can build further logic to automatically retry tasks that land in this state.
Now, let’s put our schema into practice. Here is a simple TypeScript function that demonstrates how a client or another backend service would add a new summarization job to our Firestore queue. This function uses the Firebase Admin SDK, but the client-side Web SDK follows a very similar pattern.
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
// Initialize the Firebase Admin SDK elsewhere in your project
// admin.initializeApp();
const db = getFirestore();
const tasksCollection = db.collection('gemini-tasks');
/**
* Creates a new summarization task in the Firestore queue.
* @param {string} textToSummarize The text content to be summarized by Gemini.
* @returns {Promise<string>} The ID of the newly created task document.
*/
export async function enqueueSummarizationJob(textToSummarize: string): Promise<string> {
if (!textToSummarize || textToSummarize.trim() === '') {
throw new Error('Input text cannot be empty.');
}
console.log('Enqueuing a new summarization job...');
// Create a new document in the 'gemini-tasks' collection.
const taskDocRef = await tasksCollection.add({
prompt: textToSummarize,
status: 'pending', // The initial state for all new tasks
createdAt: Timestamp.now(), // Use the server-side timestamp
updatedAt: Timestamp.now(),
result: null, // Initially null
error: null, // Initially null
});
console.log(`Successfully enqueued job with ID: ${taskDocRef.id}`);
return taskDocRef.id;
}
// Example usage:
// const longArticle = "Your very long article text goes here...";
// enqueueSummarizationJob(longArticle)
// .then(taskId => console.log(`Task created: ${taskId}`))
// .catch(error => console.error("Failed to enqueue job:", error));
In this code:
We get a reference to our gemini-tasks collection.
The enqueueSummarizationJob function accepts the textToSummarize as its primary input.
We use the add() method to create a new document with a unique, auto-generated ID in the collection.
The document’s data is set according to our schema, with the crucial status field initialized to 'pending'.
We use Timestamp.now() to ensure the timestamps are generated by Firestore’s servers, which prevents any issues with client-side clock skew.
The function returns the id of the newly created document, which the client can use to track the job’s progress.
With our Firestore collection structured to act as a queue, we now need a “worker” process to pick up new tasks and execute them. This is the perfect job for a Cloud Function. Our worker will be a 2nd Gen Firestore-triggered function that automatically activates whenever a new task document is created, processes the text using the Gemini API, and updates the document with the result.
The magic of this architecture lies in the trigger. We’ll configure a Cloud Function to listen for onDocumentCreated events in our tasks collection. This creates a direct, event-driven link between a client requesting a summary and the backend service that generates it.
First, ensure you have the necessary dependencies in your functions/package.json:
{
"dependencies": {
"@google-ai/generativelanguage": "^2.1.0",
"firebase-admin": "^12.0.0",
"firebase-functions": "^5.0.0"
}
}
Now, let’s write the function skeleton in functions/src/index.ts. We’ll define the trigger and set up the basic structure to access the task data.
import * as logger from "firebase-functions/logger";
import { onDocumentCreated } from "firebase-functions/v2/firestore";
import { initializeApp } from "firebase-admin/app";
import { getFirestore } from "firebase-admin/firestore";
import { GoogleGenerativeAI } from "@google-ai/generativelanguage";
// Initialize Firebase Admin SDK
initializeApp();
// Define the structure of our task document for type safety
interface Task {
status: 'pending' | 'processing' | 'completed' | 'error';
originalText: string;
createdAt: admin.firestore.Timestamp;
summary?: string;
errorMessage?: string;
}
// Define the Cloud Function triggered by new documents in the 'tasks' collection
export const processTaskQueue = onDocumentCreated("tasks/{taskId}", async (event) => {
const { taskId } = event.params;
const snapshot = event.data;
if (!snapshot) {
logger.warn(`No data associated with the event for taskId: ${taskId}`);
return;
}
logger.info(`[${taskId}] ==> New task received. Kicking off processing.`);
const taskData = snapshot.data() as Task;
// Mark the task as 'processing' immediately to prevent race conditions
const taskRef = snapshot.ref;
await taskRef.update({ status: "processing" });
// --- Core Gemini API logic will go here ---
});
In this setup:
We import the necessary modules from the Firebase and Google AI SDKs.
onDocumentCreated("tasks/{taskId}", ...) is the key. It tells Cloud Functions to execute our code whenever a document is added to the tasks collection. The {taskId} is a wildcard that captures the unique ID of the document.
We immediately update the task’s status to 'processing'. This is a crucial step. It provides real-time feedback to the client and acts as a lock, signaling that this task is being handled.
Hardcoding API keys is a major security risk. We’ll leverage Google Cloud’s Secret Manager, which integrates seamlessly with Cloud Functions, to store our Gemini API key.
First, store your key as a secret (you can do this via the gcloud CLI or the Google Cloud Console):
# Replace YOUR_API_KEY with your actual Gemini API key
echo "YOUR_API_KEY" | gcloud secrets create gemini-api-key --data-file=-
# Grant your function's service account access to the secret
gcloud secrets add-iam-policy-binding gemini-api-key \
--member="serviceAccount:[email protected]" \
--role="roles/secretmanager.secretAccessor"
Now, we can modify our function to access this secret and call the Gemini API. We’ll use the defineString parameter to inject the secret value as an environment variable.
Let’s flesh out the core logic inside our function:
// ... (imports and initialization from previous snippet)
// Define the secret and function options
const GEMINI_API_KEY = defineString("GEMINI_API_KEY");
export const processTaskQueue = onDocumentCreated(
{
document: "tasks/{taskId}",
secrets: [GEMINI_API_KEY], // Declare the secret dependency
},
async (event) => {
// ... (snapshot and data retrieval from previous snippet)
// Mark the task as 'processing'
const taskRef = snapshot.ref;
await taskRef.update({ status: "processing" });
try {
// 1. Initialize the Gemini client with the secure API key
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY.value());
const model = genAI.getGenerativeModel({ model: "gemini-pro" });
// 2. Construct the prompt for summarization
const { originalText } = snapshot.data() as Task;
const prompt = `Please provide a concise, one-paragraph summary of the following text:\n\n"${originalText}"`;
// 3. Call the API to generate the content
logger.info(`[${taskId}] Generating summary with Gemini...`);
const result = await model.generateContent(prompt);
const response = result.response;
const summary = response.text();
logger.info(`[${taskId}] <== Summary generated successfully.`);
// --- Update status and handle errors (next section) ---
} catch (error) {
// --- Error handling logic (next section) ---
}
}
);
Here’s the breakdown:
We use defineString("GEMINI_API_KEY") to declare a parameter for our secret.
In the function options, secrets: [GEMINI_API_KEY] tells Cloud Functions to fetch the secret from Secret Manager and make it available via GEMINI_API_KEY.value().
We initialize the GoogleGenerativeAI client using this securely accessed key.
We create a simple but effective prompt using the originalText from our task document.
model.generateContent(prompt) makes the actual call to the Gemini API.
We extract the generated text from the response, which is our summary.
The final piece of the worker is the most critical for a robust system: updating the task’s final state and gracefully handling any failures. A task isn’t truly “done” until its status is updated in Firestore.
We’ll wrap our API call in a try...catch block.
On Success (try): We update the document with a 'completed' status and the generated summary.
On Failure (catch): We log the detailed error for debugging and update the document with an 'error' status and a user-friendly error message.
Here is the complete, production-ready code for our worker function:
import * as logger from "firebase-functions/logger";
import { onDocumentCreated, defineString } from "firebase-functions/v2/firestore";
import { initializeApp } from "firebase-admin/app";
import { getFirestore, Timestamp } from "firebase-admin/firestore";
import { GoogleGenerativeAI } from "@google-ai/generativelanguage";
initializeApp();
const db = getFirestore();
interface Task {
status: 'pending' | 'processing' | 'completed' | 'error';
originalText: string;
createdAt: Timestamp;
summary?: string;
errorMessage?: string;
}
// Define the secret and function options
const GEMINI_API_KEY = defineString("GEMINI_API_KEY");
export const processTaskQueue = onDocumentCreated(
{
document: "tasks/{taskId}",
secrets: [GEMINI_API_KEY],
// Optional: Configure memory and timeout for potentially long tasks
memory: "512MiB",
timeoutSeconds: 120,
},
async (event) => {
const { taskId } = event.params;
const snapshot = event.data;
if (!snapshot) {
logger.warn(`No data associated with the event for taskId: ${taskId}`);
return;
}
const taskRef = db.collection("tasks").doc(taskId);
// Mark the task as 'processing'
await taskRef.update({ status: "processing" });
logger.info(`[${taskId}] ==> Task marked as 'processing'.`);
try {
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY.value());
const model = genAI.getGenerativeModel({ model: "gemini-pro" });
const { originalText } = snapshot.data() as Task;
if (!originalText || originalText.trim() === "") {
throw new Error("Input text is empty or missing.");
}
const prompt = `Please provide a concise, one-paragraph summary of the following text:\n\n"${originalText}"`;
logger.info(`[${taskId}] Generating summary with Gemini...`);
const result = await model.generateContent(prompt);
const summary = result.response.text();
// SUCCESS: Update Firestore with the completed status and result
await taskRef.update({
status: "completed",
summary: summary,
processedAt: Timestamp.now(),
});
logger.info(`[${taskId}] <== Task completed successfully.`);
} catch (error) {
logger.error(`[${taskId}] Error processing task:`, error);
// FAILURE: Update Firestore with the error status and message
await taskRef.update({
status: "error",
errorMessage: error instanceof Error ? error.message : "An unknown error occurred.",
processedAt: Timestamp.now(),
});
}
}
);
This final version includes:
A try...catch block for robust error handling.
On success, we write the summary and a processedAt timestamp to the document.
On failure, we log the error for our own debugging and write a clean errorMessage back to Firestore. This is invaluable for monitoring the health of your queue and diagnosing issues with specific tasks.
A basic validation check to ensure originalText is not empty.
With this function deployed, our backend is now fully equipped to listen for new tasks, process them securely and efficiently, and report the outcome directly back to our database.
This is where the magic happens. We’ll bridge the gap between the familiar interface of Automated Discount Code Management System (like a simple Google Sheet) and our powerful, scalable backend. [Architecting Multi Tenant AI Workflows in Building Modular Agentic Apps Script with Gemini Function Calling](https://votuduc.com/architecting-multi-tenant-ai-workflows-in-google-apps-script-p-20260321290501) acts as the orchestrator, allowing a user to trigger a complex AI workflow with a simple button click, without ever leaving their document.
The most intuitive way to give users access to our Gemini processing pipeline is through a custom menu directly within their Google Sheet or Doc. Apps Script makes this incredibly straightforward. When the user opens their document, we can add a new menu that exposes our custom functions.
Let’s use a Google Sheet as our primary example. Imagine a sheet where Column A contains a list of prompts for Gemini. We’ll add a menu item that, when clicked, takes the prompt from the selected row, adds it to our Firestore queue, and later retrieves the result.
First, you’ll need to open your Google Sheet and go to Extensions > Apps Script to open the script editor.
Here’s the code to create a custom menu. This onOpen function is a special trigger that runs automatically every time the spreadsheet is opened.
// This function runs when the spreadsheet is opened.
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Gemini AI Tools')
.addItem('Process Selected Row', 'submitSelectedRowToQueue')
.addItem('Fetch Completed Results', 'fetchResults')
.addToUi();
}
// A placeholder for the function that will be called by our menu item.
function submitSelectedRowToQueue() {
// We will build this function out in the next section.
SpreadsheetApp.getActiveSpreadsheet().toast('Submitting job to the queue...');
}
// A placeholder for fetching results.
function fetchResults() {
// We will build this function out later.
SpreadsheetApp.getActiveSpreadsheet().toast('Checking for completed jobs...');
}
Save this script. The next time you refresh or open your Google Sheet, you’ll see a new “Gemini AI Tools” menu at the top. This simple menu provides the user-facing controls for our entire system.
Apps Script can’t natively talk to Firestore. To do this securely and efficiently, we’ll use a fantastic community-maintained library that handles the complexities of authentication and API calls.
1. Add the Firestore Library
We’ll use the FirestoreGoogleAppsScript library by Grahamearley.
In your Apps Script editor, click the* +** icon next to “Libraries”.
In the “Script ID” field, paste the following ID: 1VUSl4b1r1Ns_hN_AD36y2GNNB62PYAz3w2cl9fG2v9oO_s_TCi_d2sB_
Click “Look up”. The latest version should be selected automatically.
Ensure the “Identifier” is Firestore (it should default to this).
Click “Add”.
2. Set Up Authentication with a Service Account
To allow our script to write to Firestore, we need to create a service account with the correct permissions.
In the Google Cloud Console, navigate to IAM & Admin > Service Accounts.
Click + CREATE SERVICE ACCOUNT.
Give it a name (e.g., apps-script-task-writer) and a description.
Grant it the* Cloud Datastore User** role. This role provides permissions to read and write to Firestore databases.
Click “Done”.
Find your newly created service account in the list, click the three-dot menu under “Actions”, and select Manage keys.
Click ADD KEY > Create new key, choose JSON, and click CREATE. A JSON key file will be downloaded to your computer.
3. Store Credentials Securely in Script Properties
Never paste your private key directly into your code. Apps Script provides “Script Properties” for securely storing sensitive information.
In the Apps Script editor, click the “Project Settings” (gear icon) on the left.
Scroll down to “Script Properties” and click Add script property.
Open the JSON key file you downloaded. You’ll need three values from it: project_id, client_email, and private_key.
Add three script properties:
firestore_project_id: Your project_id from the JSON file.
firestore_client_email: The client_email from the JSON file.
firestore_private_key: The entire private_key from the JSON file (including the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines).
4. Write the Code to Add a Task
Now we can flesh out our submitSelectedRowToQueue function. This code will authenticate with Firestore, read data from the active row in our sheet, and create a new document in our jobs collection.
/**
* Gets the required credentials from Script Properties.
* @returns {object} An object containing the service account credentials.
*/
function getFirestoreCredentials() {
const scriptProperties = PropertiesService.getScriptProperties();
return {
projectId: scriptProperties.getProperty('firestore_project_id'),
clientEmail: scriptProperties.getProperty('firestore_client_email'),
privateKey: scriptProperties.getProperty('firestore_private_key')
};
}
/**
* Submits the prompt from the currently selected row to the Firestore queue.
*/
function submitSelectedRowToQueue() {
const sheet = SpreadsheetApp.getActiveSheet();
const range = sheet.getActiveRange();
const row = range.getRow();
// Assuming prompt is in Column A (index 1)
const prompt = sheet.getRange(row, 1).getValue();
if (!prompt) {
SpreadsheetApp.getUi().alert('The selected row does not contain a prompt in Column A.');
return;
}
try {
const { projectId, clientEmail, privateKey } = getFirestoreCredentials();
// Initialize the Firestore service
const firestore = Firestore.getFirestore(clientEmail, privateKey, projectId);
// This is the data we'll write to Firestore.
// This is the "task" that our Cloud Function will pick up.
const jobData = {
prompt: prompt,
status: 'pending', // The initial state of our job
createdAt: new Date().toISOString(),
sheetRow: row // Store the row number to write the result back later
};
// Create a new document in the 'jobs' collection
const newDoc = firestore.createDocument('jobs', jobData);
// Give the user feedback
SpreadsheetApp.getActiveSpreadsheet().toast(`Job submitted successfully! (ID: ${newDoc.name.split('/').pop()})`);
} catch (e) {
Logger.log(e);
SpreadsheetApp.getUi().alert(`An error occurred: ${e.message}`);
}
}
With this in place, a user can now select a row in their Google Sheet, click Gemini AI Tools > Process Selected Row, and a new job document will instantly appear in your Firestore jobs collection, ready to be processed.
Our backend works asynchronously. The Apps Script submits the job and moves on; it doesn’t wait for the result. To get the result back into the sheet, we need a mechanism to check the status of our jobs. The most practical approach in Apps Script is “polling”—periodically querying Firestore to see which jobs are done.
We’ll implement the fetchResults function that we linked to our custom menu. When a user clicks “Fetch Completed Results”, this script will query Firestore for any jobs with a status of complete, write the result into the correct row, and then update the job’s status to prevent it from being processed again.
/**
* Fetches completed jobs from Firestore and writes the results back to the sheet.
*/
function fetchResults() {
const sheet = SpreadsheetApp.getActiveSheet();
const ui = SpreadsheetApp.getUi();
let updatedCount = 0;
try {
const { projectId, clientEmail, privateKey } = getFirestoreCredentials();
const firestore = Firestore.getFirestore(clientEmail, privateKey, projectId);
// Query the 'jobs' collection for documents where status is 'complete'
const completedJobs = firestore.query('jobs').where('status', '==', 'complete').get();
if (completedJobs.length === 0) {
SpreadsheetApp.getActiveSpreadsheet().toast('No new completed jobs found.');
return;
}
// Process each completed job
for (const job of completedJobs) {
const jobData = job.fields;
const documentPath = job.name; // e.g., projects/project-id/databases/(default)/documents/jobs/documentId
// Ensure we have the necessary data to proceed
if (jobData.sheetRow && jobData.result) {
const targetRow = jobData.sheetRow;
const resultText = jobData.result;
// Write the result to Column B (index 2)
sheet.getRange(targetRow, 2).setValue(resultText);
updatedCount++;
// IMPORTANT: Update the document to prevent re-processing
// We change the status to 'archived' or 'retrieved'
firestore.updateDocument(documentPath, { status: 'archived' });
}
}
if (updatedCount > 0) {
SpreadsheetApp.getActiveSpreadsheet().toast(`${updatedCount} result(s) have been written to the sheet.`);
} else {
SpreadsheetApp.getActiveSpreadsheet().toast('Found completed jobs, but could not write results. Check job data.');
}
} catch (e) {
Logger.log(e);
ui.alert(`An error occurred while fetching results: ${e.message}`);
}
}
Now, the user has a complete workflow. They can submit jobs with one menu click and retrieve the results with another, turning their simple Google Sheet into a powerful front-end for your scalable Gemini AI processing pipeline.
With the basic queueing mechanism in place, it’s time to harden our system for production. A “fire-and-forget” approach works for prototypes, but real-world workloads demand resilience, cost control, and observability. Let’s fine-tune our setup to handle failures gracefully, respect API limits, and provide clear insights into its operational health.
When a flood of tasks hits your queue, Cloud Functions will eagerly try to process them all by spinning up new function instances. While powerful, this unconstrained scaling can lead to significant problems:
API Rate Limiting: The Gemini API, like any robust service, has rate limits (e.g., requests per minute). Exceeding these limits will result in 429 Too Many Requests errors, causing your jobs to fail.
Cost Overruns: Each function instance incurs costs. An unexpected burst of tasks could lead to a surprisingly high bill if not properly constrained.
Downstream Pressure: If your function interacts with other services, like a database, unbridled concurrency can overwhelm them, causing a cascading failure.
The solution is to deliberately control the maximum number of function instances that can run simultaneously. In Cloud Functions v2, this is managed directly in your function’s definition using the concurrency option.
This setting instructs the Cloud Functions service to never run more than 10 instances of this specific function at one time. If the 11th task is dispatched from the queue while 10 are already running, it will simply wait until one of the active instances finishes its work.
// src/index.ts
import { onTaskDispatched } from "firebase-functions/v2/tasks";
import { logger } from "firebase-functions";
// Set a hard limit on the number of parallel executions.
const TASK_QUEUE_OPTIONS = {
concurrency: 10, // Only allow 10 instances of this function to run at once.
};
export const processGeminiJob = onTaskDispatched(
TASK_QUEUE_OPTIONS,
async (request) => {
const jobId = request.data.jobId;
logger.info(`[${jobId}] Starting Gemini processing...`);
// ... your logic to call the Gemini API ...
logger.info(`[${jobId}] Successfully completed.`);
}
);
How do you choose the right number? Start by consulting the Gemini API documentation for its rate limits. If the limit is 60 requests per minute (RPM), setting concurrency: 10 is a safe starting point. This allows your system to handle bursts while ensuring you don’t overwhelm the API. You can then tune this value up or down based on your application’s performance and the average duration of a single task.
Network connections flicker. APIs have transient hiccups. A robust system must anticipate and handle these temporary failures. Dropping a user’s request because of a momentary 503 Service Unavailable error from an API is not an acceptable production experience.
Thankfully, Cloud Tasks has a powerful, built-in retry mechanism that you can configure directly on your function. When your function handler throws an error, Cloud Tasks will catch it and automatically schedule the task to be run again later, using an exponential backoff strategy.
This strategy is crucial: instead of immediately hammering the potentially struggling API again, it waits for a short period, then a longer one, and so on. This gives the downstream service time to recover.
You can configure this behavior using the retryConfig options:
maxAttempts: The total number of times a task should be tried (including the initial attempt).
minBackoffSeconds: The minimum time to wait after a failure before the first retry.
maxBackoffSeconds: The maximum time to wait between retries.
// src/index.ts
import { onTaskDispatched } from "firebase-functions/v2/tasks";
import { logger } from "firebase-functions";
const TASK_QUEUE_OPTIONS = {
concurrency: 10,
retryConfig: {
maxAttempts: 5, // Try a total of 5 times
minBackoffSeconds: 10, // Wait at least 10s after a failure
maxBackoffSeconds: 300, // Wait at most 5 minutes between retries
},
};
export const processGeminiJob = onTaskDispatched(
TASK_QUEUE_OPTIONS,
async (request) => {
const jobId = request.data.jobId;
try {
logger.info(`[${jobId}] Starting Gemini processing, attempt #${request.retryCount}`);
// ... your logic to call the Gemini API ...
// If the API call fails, it will throw an error.
logger.info(`[${jobId}] Successfully completed.`);
} catch (error) {
logger.error(`[${jobId}] Failed on attempt #${request.retryCount}. Error: ${error.message}`);
// Re-throw the error to signal to Cloud Tasks that it should retry.
throw error;
}
}
);
**A critical note on Idempotency: When you enable retries, you must ensure your function logic is idempotent. This means that executing the function multiple times with the same input produces the same result and has no unintended side effects. For example, if your function saves the Gemini result to Firestore, it should use the jobId as the document ID. This way, a successful retry will simply overwrite the document with the same data, rather than creating a duplicate entry.
Once your system is live, you can’t fly blind. You need visibility into its performance to diagnose issues, identify bottlenecks, and make informed scaling decisions. The Google Cloud Console provides excellent tools for this.
Navigate to the Cloud Tasks and Cloud Functions sections in your GCP project to find dashboards that visualize key metrics.
Key Metrics to Watch:
tasks_in_queue): This is the number of tasks waiting to be processed.What it tells you: A healthy queue depth should hover near zero. If it’s steadily increasing over time, it’s a clear signal that tasks are being created faster than your functions can process them.
Action: You may need to increase your function’s concurrency limit or optimize your function’s code to reduce its execution time.
dispatch_count): The number of tasks being sent to your functions per unit of time.What it tells you: This metric represents your system’s current throughput. It helps you understand how many jobs you’re processing successfully.
Action: Compare this to your business needs. If the rate is lower than expected, it could indicate a problem either in task creation or processing.
execution_count with status=error): The number of function invocations that are failing.What it tells you: A spike in errors is an immediate red flag. It could be a bug you deployed, a change in the Gemini API, or an expired API key.
Action: Dive into the logs for your Cloud Function. Structured logging is your best friend here. By logging the jobId and specific error messages, you can quickly pinpoint the cause of the failures.
Setting Up Alerts:
For a production system, you shouldn’t have to manually check dashboards. Use Cloud Monitoring to create alerting policies. A classic and highly effective alert is to notify your team via Slack or PagerDuty if the queue depth remains above a certain threshold (e.g., 1000 tasks) for an extended period (e.g., 15 minutes). This proactive monitoring allows you to address scaling issues before they impact your users.
We started this journey with a common, frustrating problem: a powerful generative AI task, perfect for Gemini, that was too slow for the ephemeral world of standard cloud functions. The dreaded timeout error wasn’t just a technical nuisance; it was a hard ceiling on our application’s capabilities. By introducing a Firebase Task Queue, we didn’t just fix the error—we fundamentally transformed our architecture from a fragile, synchronous process into a robust, scalable, and asynchronous system.
Let’s quickly revisit the elegant and powerful pattern we’ve built. Instead of a single, monolithic function trying to do everything at once, we’ve implemented a distributed workflow:
The Trigger: An event, like a new document being created in Firestore, initiates the process.
The Dispatcher: A lightweight, rapid-response Cloud Function acts as the gatekeeper. Its only job is to receive the initial request, perform any necessary validation, and immediately enqueue a task into the Firebase Task Queue. It finishes in milliseconds, ensuring a snappy user experience.
The Queue: This is the resilient heart of our system. The Task Queue securely holds the job, guaranteeing its eventual execution. It handles retries, backoff strategies, and scheduling, abstracting away the complexities of reliable task management.
The Worker: A second, long-timeout Cloud Function is configured as the task handler. This is our workhorse. Invoked by the queue, it has ample time (up to 60 minutes) to perform the heavy lifting—calling the Gemini API, processing the results, and handling complex logic without the pressure of a ticking clock.
The Result: The worker function writes the final output, like our generated summary, back to Firestore, effectively closing the loop and making the result available to the rest of our application.
This separation of concerns is the key. We’ve decoupled the task initiation from its execution, creating a system that is not only resilient to timeouts but also inherently scalable. As the number of requests grows, the queue simply lines them up, and we can configure the concurrency to process them at a manageable and cost-effective rate.
While we focused on text summarization, this asynchronous architecture is a versatile blueprint for a vast array of long-running AI and data processing tasks. Think of it as a general-purpose engine for any job that can’t be completed in a few seconds.
Here are just a few ideas to get you started:
Multimedia Processing:
Video Analysis: A user uploads a video. A worker function could use Gemini 1.5 Pro to transcribe the audio, generate chapter markers based on visual cues, and create a concise summary of the content.
Audio Transcription & Enhancement: Process podcast episodes or meeting recordings to generate transcripts, identify different speakers, and even perform audio cleanup.
Batch Data & Image Analysis:
Generative Image Tagging: When a user uploads a gallery of photos, enqueue a task for each one to generate descriptive alt text, identify objects, or even apply artistic filters using multimodal AI models.
Report Generation: Kick off a process to analyze a large dataset from a CSV in Cloud Storage, have Gemini extract key insights and trends, and compile the findings into a formatted PDF report.
RAG (Building a RAG Context Manager with Apps Script and Gemini Pro) Workflows:
Document Indexing: When a user uploads a large PDF or a collection of documents, a worker can chunk the text, generate embeddings using a text-embedding model, and populate a vector database. This is a critical, time-consuming background job that is a perfect fit for a task queue.
Anytime you find yourself thinking, “This API call might take a while,” this pattern should be your go-to solution.
You now have a solid foundation for building scalable AI features. Before deploying to production, however, consider these crucial next steps to ensure your system is robust, secure, and cost-effective.
Enhance Error Handling & Observability: What happens if the Gemini API returns an error or your worker function fails for an unexpected reason? Implement comprehensive try...catch blocks and use Cloud Logging to record detailed information about both successful and failed executions. Configure your Task Queue’s retry policy thoughtfully. Set up alerts in Cloud Monitoring to notify you if the failure rate for a queue exceeds a certain threshold.
Implement Cost Controls: Scalability is a double-edged sword. To prevent runaway costs, configure the maxDispatchesPerSecond and maxConcurrentDispatches settings in your queue.yaml or via the gcloud CLI. This acts as a throttle, controlling the maximum rate at which you’re invoking functions and, by extension, making API calls to Gemini. Always set up budget alerts in your Google Cloud project.
Design for Idempotency: In a distributed system, a task might occasionally be executed more than once (e.g., due to a network issue and an automatic retry). Design your worker function to be idempotent—meaning that running it multiple times with the same input produces the same result. A simple way to achieve this is to have the worker check if the result already exists in Firestore before starting its work.
Secure Your Endpoints: Protect your functions. Use Firebase App Check to ensure that your dispatcher function is only triggered by your legitimate client applications. Follow the principle of least privilege by configuring the specific IAM roles your functions need to access other services like Firestore and the Gemini API, and nothing more.
By embracing this asynchronous mindset, you’re no longer limited by platform constraints. You’re free to orchestrate complex, long-running AI jobs with confidence, building applications that are more powerful, reliable, and ready for whatever scale you throw at them.
Quick Links
Legal Stuff
