How to Build an Async Gemini Job Pipeline with Cloud Tasks and Cloud Run Google Cloud's Cloud Tasks and Cloud Run can be used to build an asynchronous pipeline for Gemini AI jobs, according to a technical article. The architecture separates request handling from AI processing by accepting a job, storing it in Firestore, and processing it in the background, returning a 202 Accepted status with a job ID for polling. This approach avoids long HTTP requests and allows clients to poll for results. Building a background GenAI workflow with Cloud Run, Cloud Tasks, Firestore, Vertex AI, and an idempotent worker. A common GenAI API does everything inside a single HTTP request: User request ↓API ↓Gemini ↓Wait for generation ↓Return response That is perfectly reasonable when inference is fast. But once the model call starts taking several seconds, the HTTP request begins carrying more responsibility than it should. Clients can disconnect. Retries can repeat expensive inference. The API remains occupied while the model is generating. And when something fails, request handling and AI-processing failures become mixed together. I wanted to experiment with a different model: Accept the user’s request quickly, move the AI work outside the request lifecycle, and treat generation as a background job. The result was a small asynchronous Gemini pipeline using Google Cloud. The workflow is: POST /jobs ↓Firestore ↓Cloud Tasks ↓Cloud Run worker ↓Vertex AI Gemini ↓Firestore ↓GET /jobs/:jobId The stack consists of: Instead of waiting for Gemini, the client receives a job ID immediately. It can then poll the job until processing finishes. There are three distinct responsibilities: Separating those responsibilities is the main idea behind the architecture. I kept the implementation relatively small: src/├── server.js├── jobs.js├── tasks.js├── gemini.js├── db.js├── auth.js└── config.js Each file has one main responsibility: server.js→ Express setup and route wiring jobs.js→ job creation, polling and worker logic tasks.js→ Cloud Tasks integration gemini.js→ Vertex AI Gemini client db.js→ Firestore initialization auth.js→ internal worker authentication config.js→ environment configuration The architecture in this article follows the actual implementation rather than presenting a separate theoretical design. The public API exposes two main routes: POST /jobsGET /jobs/:jobId The first route accepts a prompt. Instead of sending that prompt directly to Gemini, it creates a Firestore document. js app.post "/jobs", async req, res = { try { const { prompt } = req.body; const requestId = req.headers "x-request-id" || uuidv4 ; if prompt || typeof prompt == "string" || prompt.trim { return res.status 400 .json { error: "prompt is required" } ; } const jobId = uuidv4 ; console.log "JOB CREATE REQUEST", { requestId, jobId } ; await db.collection "jobs" .doc jobId .set { jobId, prompt: prompt.trim , status: "QUEUED", attemptCount: 0, taskName: null, result: null, error: null, createdAt: FieldValue.serverTimestamp , updatedAt: FieldValue.serverTimestamp , processingStartedAt: null, completedAt: null } ; console.log "JOB CREATED", { requestId, jobId } ; const taskName = await enqueueJob jobId ; console.log "TASK ENQUEUED", { requestId, jobId, taskName } ; await db.collection "jobs" .doc jobId .update { taskName, updatedAt: FieldValue.serverTimestamp } ; return res.status 202 .json { jobId, status: "QUEUED" } ; } catch error { console.error "CREATE JOB ERROR", error ; return res.status 500 .json { error: "Unable to create job" } ; }} ; The important response here is: 202 Accepted The API is not claiming that the work is finished. It is saying: The job has been accepted and will be processed separately. A typical response looks like: { "jobId": "af7ec305-ef02-4fcc-a577-b61a1d1f1fd6", "status": "QUEUED"} The client can now continue without keeping the original HTTP request open. Creating the Firestore document gives us durable state. Now we need something to trigger processing. That is where Cloud Tasks comes in. The API creates an HTTP task containing only the job ID: js async function enqueueJob jobId { const parent = tasksClient.queuePath process.env.PROJECT ID, process.env.REGION, process.env.QUEUE ID ; const task = { httpRequest: { httpMethod: "POST", url: ${process.env.SERVICE URL} + /internal/process-job , headers: { "Content-Type": "application/json", "X-Internal-Task-Secret": process.env.INTERNAL TASK SHARED SECRET }, body: Buffer.from JSON.stringify { jobId } .toString "base64" , oidcToken: { serviceAccountEmail: process.env.TASK INVOKER SERVICE ACCOUNT, audience: process.env.SERVICE URL } } }; const response = await tasksClient.createTask { parent, task } ; return response.name;} The queue is the boundary between: accepting work and executing work That distinction becomes important as soon as processing becomes slow or failure-prone. My implementation uses two checks on the worker request. Cloud Tasks sends an OIDC token using a dedicated service account. That provides the Google Cloud identity layer for the request. The implementation also sends: X-Internal-Task-Secret which is checked by the application through the internal authentication middleware. So conceptually: Cloud Tasks │ │ OIDC identity ↓Cloud Run │ │ application-level gate ↓Worker The shared secret is an additional application-level check in this implementation; it is not a replacement for IAM authentication. A background worker should not assume a task can only ever reach it once. The worker therefore needs to tolerate duplicate execution. My implementation uses three protections: terminal-state checks + Firestore processing lease + attempt counting The worker starts by trying to claim the job. js const claim = await claimJobForProcessing ref ; The claim can produce several outcomes. A missing job: if claim.kind === "missing" { return res.status 404 .json { error: "Job not found" } ;} A job that has already completed: if claim.kind === "terminal" { return res.status 200 .json { status: "already completed" } ;} Or a job that another worker currently owns: if claim.kind === "leased" { return res.status 200 .json { status: "already processing" } ;} Only the worker that successfully claims the job proceeds to Gemini. Without that protection, two deliveries could potentially produce: Task delivery A ──→ Gemini Task delivery B ──→ Gemini for the same logical job. The lease turns it into: Task delivery A ↓ claims job ↓ Gemini Task delivery B ↓ sees active lease ↓ stops That is a small implementation detail with a large operational impact. Once the job has been claimed, the worker calls the Gemini integration from gemini.js. Conceptually: js const result = await generateWithGemini claim.job.prompt ; If generation succeeds, the worker updates the Firestore document: await ref.update { status: "COMPLETED", result, error: null, processingLeaseUntil: null, completedAt: FieldValue.serverTimestamp , updatedAt: FieldValue.serverTimestamp } ; The job now has a durable final state. The client does not need access to the worker or Gemini. It only needs to ask: GET /jobs/:jobId The normal path is: QUEUED ↓PROCESSING ↓COMPLETED Firestore becomes the source of truth for that state. That means a client can disappear and return later without losing track of the AI operation. Instead of tying state to an open HTTP connection, we tie it to a durable job document. The current worker treats caught processing failures as terminal failures. catch error { console.error "PROCESS JOB ERROR", { jobId, error } ; await ref.update { status: "FAILED", error: "PERMANENT FAILURE", processingLeaseUntil: null, completedAt: FieldValue.serverTimestamp , updatedAt: FieldValue.serverTimestamp } ; return res.status 200 .json { status: "handled failure" } ;} The 200 response is deliberate. It tells Cloud Tasks: The task was handled. Do not continue retrying this particular failure. So the current implementation does not automatically retry every Gemini failure. That is an important distinction. The architecture gives us a clean place to introduce more sophisticated retry behavior later. For example: temporary timeout429503network error ↓retryable failure versus: invalid inputunsupported operationpermanent application failure ↓terminal failure A future version could return a non-2xx response only for errors explicitly classified as transient. The current version keeps the behavior simpler and observable. Run the pipeline end to end using: Explain caching for LLM latency. The Firestore job moved through: QUEUED→ PROCESSING→ COMPLETED It would be possible to enqueue a prompt directly into Cloud Tasks. I deliberately store the job first. That gives the application a persistent record of: what was requestedwhen it was createdcurrent stateprocessing startcompletion timeresultfailureattempt informationtask identifier So Firestore isn’t simply being used as storage for the Gemini response. It is functioning as the job-state layer . That becomes valuable for debugging as well. Instead of asking: Did the AI request fail? we can ask: Was the job created? Was a task created? Was processing claimed? Did Gemini execute? Was the result persisted? Those are much easier questions to investigate. This architecture is useful when: Examples could include: document analysislong-form generationimage processingbatch enrichmentreport generationvideo processingbackground AI evaluations The additional infrastructure is not free. You now have: Cloud Run + Cloud Tasks + Firestore + IAM + job lifecycle management For a tiny application where Gemini reliably responds in one or two seconds and the user needs the answer immediately, that may be unnecessary complexity. A normal synchronous endpoint can be the better design. The async architecture becomes valuable when the lifecycle of the AI work no longer matches the lifecycle of the user request. The interesting part of this experiment was not calling Gemini. That part was straightforward. The more useful engineering work was everything around it: How is the job represented? Who owns execution? How is the worker authenticated? What happens if a task arrives twice? How does the client know the current state? Where do failures become visible? What happens after the original HTTP request is gone? Those questions exist in traditional distributed systems too. LLM workloads do not make them disappear. If anything, variable inference latency and expensive generation make them more important. GITHUB Repo Link : https://github.com/arijit1/async-gemini-cloud-tasks https://github.com/arijit1/async-gemini-cloud-tasks Find Google Cloud Setup Steps at https://github.com/arijit1/async-gemini-cloud-tasks/blob/main/README.md https://github.com/arijit1/async-gemini-cloud-tasks/blob/main/README.md LinkedIn : https://www.linkedin.com/in/arijit-sengupta- https://www.linkedin.com/in/arijit-sengupta- How to Build an Async Gemini Job Pipeline with Cloud Tasks and Cloud Run https://blog.stackademic.com/how-to-build-an-async-gemini-job-pipeline-with-cloud-tasks-and-cloud-run-9aabd07e6bbf was originally published in Stackademic https://blog.stackademic.com on Medium, where people are continuing the conversation by highlighting and responding to this story.