{"slug": "how-to-build-an-async-gemini-job-pipeline-with-cloud-tasks-and-cloud-run", "title": "How to Build an Async Gemini Job Pipeline with Cloud Tasks and Cloud Run", "summary": "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.", "body_md": "*Building a background GenAI workflow with Cloud Run, Cloud Tasks, Firestore, Vertex AI, and an idempotent worker.*\n\nA common GenAI API does everything inside a single HTTP request:\n\n```\nUser request    ↓API    ↓Gemini    ↓Wait for generation    ↓Return response\n```\n\nThat is perfectly reasonable when inference is fast.\n\nBut once the model call starts taking several seconds, the HTTP request begins carrying more responsibility than it should.\n\nClients can disconnect.\n\nRetries can repeat expensive inference.\n\nThe API remains occupied while the model is generating.\n\nAnd when something fails, request handling and AI-processing failures become mixed together.\n\nI wanted to experiment with a different model:\n\n***Accept the user’s request quickly, move the AI work outside the request lifecycle, and treat generation as a background job.***\n\nThe result was a small asynchronous Gemini pipeline using Google Cloud.\n\nThe workflow is:\n\n```\nPOST /jobs    ↓Firestore    ↓Cloud Tasks    ↓Cloud Run worker    ↓Vertex AI Gemini    ↓Firestore    ↓GET /jobs/:jobId\n```\n\nThe stack consists of:\n\nInstead of waiting for Gemini, the client receives a job ID immediately.\n\nIt can then poll the job until processing finishes.\n\nThere are three distinct responsibilities:\n\nSeparating those responsibilities is the main idea behind the architecture.\n\nI kept the implementation relatively small:\n\n```\nsrc/├── server.js├── jobs.js├── tasks.js├── gemini.js├── db.js├── auth.js└── config.js\n```\n\nEach file has one main responsibility:\n\n```\nserver.js→ Express setup and route wiring\njobs.js→ job creation, polling and worker logic\ntasks.js→ Cloud Tasks integration\ngemini.js→ Vertex AI Gemini client\ndb.js→ Firestore initialization\nauth.js→ internal worker authentication\nconfig.js→ environment configuration\n```\n\nThe architecture in this article follows the actual implementation rather than presenting a separate theoretical design.\n\nThe public API exposes two main routes:\n\n```\nPOST /jobsGET /jobs/:jobId\n```\n\nThe first route accepts a prompt.\n\nInstead of sending that prompt directly to Gemini, it creates a Firestore document.\n\n``` js\napp.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\" });    }});\n```\n\nThe important response here is:\n\n```\n202 Accepted\n```\n\nThe API is not claiming that the work is finished.\n\nIt is saying:\n\n*The job has been accepted and will be processed separately.*\n\nA typical response looks like:\n\n```\n{  \"jobId\": \"af7ec305-ef02-4fcc-a577-b61a1d1f1fd6\",  \"status\": \"QUEUED\"}\n```\n\nThe client can now continue without keeping the original HTTP request open.\n\nCreating the Firestore document gives us durable state.\n\nNow we need something to trigger processing.\n\nThat is where Cloud Tasks comes in.\n\nThe API creates an HTTP task containing only the job ID:\n\n``` js\nasync 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;}\n```\n\nThe queue is the boundary between: accepting work and executing work\n\nThat distinction becomes important as soon as processing becomes slow or failure-prone.\n\nMy implementation uses two checks on the worker request.\n\nCloud Tasks sends an **OIDC token** using a dedicated service account.\n\nThat provides the Google Cloud identity layer for the request.\n\nThe implementation also sends:\n\n```\nX-Internal-Task-Secret\n```\n\nwhich is checked by the application through the internal authentication middleware.\n\nSo conceptually:\n\n```\nCloud Tasks      │      │ OIDC identity      ↓Cloud Run      │      │ application-level gate      ↓Worker\n```\n\nThe shared secret is an additional application-level check in this implementation; it is not a replacement for IAM authentication.\n\nA background worker should not assume a task can only ever reach it once.\n\nThe worker therefore needs to tolerate duplicate execution.\n\nMy implementation uses three protections:\n\n```\nterminal-state checks + Firestore processing lease + attempt counting\n```\n\nThe worker starts by trying to claim the job.\n\n``` js\nconst claim =  await claimJobForProcessing(ref);\n```\n\nThe claim can produce several outcomes.\n\nA missing job:\n\n```\nif (claim.kind === \"missing\") {  return res.status(404).json({    error: \"Job not found\"  });}\n```\n\nA job that has already completed:\n\n```\nif (claim.kind === \"terminal\") {  return res.status(200).json({    status: \"already_completed\"  });}\n```\n\nOr a job that another worker currently owns:\n\n```\nif (claim.kind === \"leased\") {  return res.status(200).json({    status: \"already_processing\"  });}\n```\n\nOnly the worker that successfully claims the job proceeds to Gemini.\n\nWithout that protection, two deliveries could potentially produce:\n\n```\nTask delivery A ──→ Gemini\nTask delivery B ──→ Gemini\n```\n\nfor the same logical job.\n\nThe lease turns it into:\n\n```\nTask delivery A       ↓   claims job       ↓     Gemini\nTask delivery B       ↓ sees active lease       ↓     stops\n```\n\nThat is a small implementation detail with a large operational impact.\n\nOnce the job has been claimed, the worker calls the Gemini integration from gemini.js.\n\nConceptually:\n\n``` js\nconst result =  await generateWithGemini(    claim.job.prompt  );\n```\n\nIf generation succeeds, the worker updates the Firestore document:\n\n```\nawait ref.update({  status: \"COMPLETED\",  result,  error: null,  processingLeaseUntil: null,  completedAt:    FieldValue.serverTimestamp(),  updatedAt:    FieldValue.serverTimestamp()});\n```\n\nThe job now has a durable final state.\n\nThe client does not need access to the worker or Gemini.\n\nIt only needs to ask:\n\n```\nGET /jobs/:jobId\n```\n\nThe normal path is:\n\n```\nQUEUED   ↓PROCESSING   ↓COMPLETED\n```\n\nFirestore becomes the source of truth for that state.\n\nThat means a client can disappear and return later without losing track of the AI operation.\n\nInstead of tying state to an open HTTP connection, we tie it to a durable job document.\n\nThe current worker treats caught processing failures as terminal failures.\n\n```\ncatch (error) {  console.error(    \"PROCESS_JOB_ERROR\",    {      jobId,      error    }  );\nawait ref.update({    status: \"FAILED\",    error: \"PERMANENT_FAILURE\",    processingLeaseUntil: null,    completedAt:      FieldValue.serverTimestamp(),    updatedAt:      FieldValue.serverTimestamp()  });\nreturn res.status(200).json({    status: \"handled_failure\"  });}\n```\n\nThe 200 response is deliberate.\n\nIt tells Cloud Tasks:\n\n*The task was handled. Do not continue retrying this particular failure.*\n\nSo the current implementation does **not** automatically retry every Gemini failure.\n\nThat is an important distinction.\n\nThe architecture gives us a clean place to introduce more sophisticated retry behavior later.\n\nFor example:\n\n```\ntemporary timeout429503network error    ↓retryable failure\n```\n\nversus:\n\n```\ninvalid inputunsupported operationpermanent application failure    ↓terminal failure\n```\n\nA future version could return a non-2xx response only for errors explicitly classified as transient.\n\nThe current version keeps the behavior simpler and observable.\n\nRun the pipeline end to end using:\n\n```\nExplain caching for LLM latency.\n```\n\nThe Firestore job moved through:\n\n```\nQUEUED→ PROCESSING→ COMPLETED\n```\n\nIt would be possible to enqueue a prompt directly into Cloud Tasks.\n\nI deliberately store the job first.\n\nThat gives the application a persistent record of:\n\n```\nwhat was requestedwhen it was createdcurrent stateprocessing startcompletion timeresultfailureattempt informationtask identifier\n```\n\nSo Firestore isn’t simply being used as storage for the Gemini response.\n\nIt is functioning as the **job-state layer**.\n\nThat becomes valuable for debugging as well.\n\nInstead of asking:\n\n*Did the AI request fail?*\n\nwe can ask:\n\n```\nWas the job created?\nWas a task created?\nWas processing claimed?\nDid Gemini execute?\nWas the result persisted?\n```\n\nThose are much easier questions to investigate.\n\nThis architecture is useful when:\n\nExamples could include:\n\n```\ndocument analysislong-form generationimage processingbatch enrichmentreport generationvideo processingbackground AI evaluations\n```\n\nThe additional infrastructure is not free.\n\nYou now have:\n\n```\nCloud Run + Cloud Tasks + Firestore + IAM + job lifecycle management\n```\n\nFor a tiny application where Gemini reliably responds in one or two seconds and the user needs the answer immediately, that may be unnecessary complexity.\n\nA normal synchronous endpoint can be the better design.\n\nThe async architecture becomes valuable when the lifecycle of the AI work no longer matches the lifecycle of the user request.\n\nThe interesting part of this experiment was not calling Gemini.\n\nThat part was straightforward.\n\nThe more useful engineering work was everything around it:\n\n```\nHow is the job represented?\nWho owns execution?\nHow is the worker authenticated?\nWhat happens if a task arrives twice?\nHow does the client know the current state?\nWhere do failures become visible?\nWhat happens after the original HTTP request is gone?\n```\n\nThose questions exist in traditional distributed systems too.\n\nLLM workloads do not make them disappear.\n\nIf anything, variable inference latency and expensive generation make them more important.\n\n**GITHUB** **Repo** Link : [https://github.com/arijit1/async-gemini-cloud-tasks](https://github.com/arijit1/async-gemini-cloud-tasks)\n\nFind ***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)\n\nLinkedIn : [https://www.linkedin.com/in/arijit-sengupta-](https://www.linkedin.com/in/arijit-sengupta-)\n\n[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.", "url": "https://wpnews.pro/news/how-to-build-an-async-gemini-job-pipeline-with-cloud-tasks-and-cloud-run", "canonical_source": "https://blog.stackademic.com/how-to-build-an-async-gemini-job-pipeline-with-cloud-tasks-and-cloud-run-9aabd07e6bbf?source=rss----d1baaa8417a4---4", "published_at": "2026-09-08 16:37:23+00:00", "updated_at": "2026-09-08 16:55:38.779512+00:00", "lang": "en", "topics": ["artificial-intelligence", "generative-ai"], "entities": ["Google Cloud", "Cloud Tasks", "Cloud Run", "Firestore", "Vertex AI", "Gemini"], "alternates": {"html": "https://wpnews.pro/news/how-to-build-an-async-gemini-job-pipeline-with-cloud-tasks-and-cloud-run", "markdown": "https://wpnews.pro/news/how-to-build-an-async-gemini-job-pipeline-with-cloud-tasks-and-cloud-run.md", "text": "https://wpnews.pro/news/how-to-build-an-async-gemini-job-pipeline-with-cloud-tasks-and-cloud-run.txt", "jsonld": "https://wpnews.pro/news/how-to-build-an-async-gemini-job-pipeline-with-cloud-tasks-and-cloud-run.jsonld"}}