
How to connect Convex to RunPod for serverless GPU workloads

Guest Stack Post from the community by Convex Champion, Jashwanth Peddisetty.
In this Article, I'm walking you through how I use GPU workflows in sync with Convex functions, basically letting Convex trigger GPU tasks like compression, background removal, or any AI/ML model you want and sync it directly from the GPU instance instead of waiting for API response.
Intro
So I was building a fun project where users upload video files...
The problem?
Each file was around 50–100 MB, and storing + processing them at full resolution was going to blow up my bandwidth and storage costs.
I needed a way to:
- Compress the video instantly after upload
- Store both high-res and low-res versions
- And keep everything neatly synced with my database
But that wasn't the only issue.
I also had workflows like AI background removal where no affordable API existed, but there were open-source models that needed GPUs.
This is where RunPod comes in.
RunPod lets you deploy GPU models as serverless endpoints.
Meaning:
- GPU spins up only when needed
- Runs your AI task
- Shuts down immediately after
- And you pay only for the usage minutes
Now combine that with Convex and suddenly your backend can orchestrate GPU tasks just like normal database operations. This was helpful for a very wide range of use cases that I built, so thought I bring it as a blog so that anyone who is planning to try out a GPU intensive operation can get a kickstart.
For this walkthrough, we'll implement background removal.
Here's the flow: the moment a user uploads a video, a Convex action fires off a GPU job for background removal. That GPU task runs completely in the background — it processes the video, removes the background, and then automatically uploads the final output and saves it straight into the database. All without blocking the backend or making the user wait.
Architecture diagram showing the Convex and RunPod GPU workflow
Building a GPU task
Let's build a sample GPU task.
For this walkthrough, I'm using Convex with Next.js and Convex Auth.
I'm also using the following open-source repository for video background removal:
Setting up the project
Convex provides a great CLI that lets you choose your frontend framework and authentication method, and it instantly generates all the boilerplate you need.
To start a new project, run:
1npm create convex@latest
2
Convex CLI project creation output
The first thing I usually do after generating the boilerplate is hand it over to Cursor and ask it to clean out all the default placeholder UI.
Here's the exact prompt I used to transform the starter template into a real app with authentication, an upload interface, and a simple image gallery to view uploaded files.
1Update the entire code base of @onupload-compressor to replace the place holder website after login with file uploader , store the uploaded files (can be images , videos, audio ) and store them in files as well as show them as a masonry grid
2
File upload feature prompt result
We then run the application using the command.
1npx convex dev
2
Running file upload website
Our file upload website is running well, now lets try to work on adding background removal feature as soon as file is uploaded.
Before continuing, make sure to create this table in your schema, to track the background removal status for each media file.
1removeBgJobs: defineTable({
2 fileId: v.id("_storage"),
3 status: v.optional(v.union(
4 v.literal("pending"),
5 v.literal("processing"),
6 v.literal("completed"),
7 v.literal("failed")
8 )),
9 resultId: v.optional(v.id("_storage"))
10})
11This table stores the lifecycle of a background-removal job for a media file. It links the original uploaded file (fileId) to its processing state (status) and, once completed, to the processed output (resultId).
Building background removal serverless API
I have created the runpod handler which does three things
step 1 : accepts a URL of the image/video of which the background is to be removed
step 2: process the background removal task
step 3 : upload the background removed video into convex directly from the runpod instance
Let's start with writing required convex functions that invokes the Runpod serverless API.
Convex functions overview
Internal mutation: updateRemoveBgInternal
This internal mutation updates the status and result of a background-removal job. It's designed to be called only from trusted server-side code (like actions), ensuring controlled updates to job state without exposing this mutation to clients.
Action: startRemoveBgWorker
This action starts the background-removal pipeline. It fetches a public URL for the uploaded video, marks the job as pending, and triggers the Runpod serverless worker with the required inputs. Because it's an action, it can safely call external APIs while still coordinating state changes through Convex mutations.
1export const updateRemoveBgInternal = internalMutation({
2 args: {
3 removeBgJobId: v.id("removeBgJobs"),
4 status: v.optional(
5 v.union(
6 v.literal("pending"),
7 v.literal("processing"),
8 v.literal("completed"),
9 v.literal("failed")
10 )
11 ),
12 resultId: v.optional(v.id("_storage")),
13 },
14 returns: v.null(),
15 handler: async (ctx, args) => {
16 const updateData: {
17 status?: "pending" | "processing" | "completed" | "failed";
18 resultId?: Id<"_storage">;
19 } = {};
20
21 if (args.status !== undefined) {
22 updateData.status = args.status;
23 }
24
25 if (args.resultId !== undefined) {
26 updateData.resultId = args.resultId;
27 }
28
29 await ctx.db.patch(args.removeBgJobId, updateData);
30 return null;
31 },
32});
33
34
35export const startRemoveBgWorker = action({
36 args: {
37 removeBgJobId: v.id("removeBgJobs"),
38 videoFileId: v.id("_storage"),
39 },
40 returns: v.object({
41 jobId: v.string(),
42 }),
43 handler: async (ctx, args) => {
44 const videoUrl = await ctx.storage.getUrl(args.videoFileId);
45 if (!videoUrl) {
46 throw new Error("Video file not found");
47 }
48
49 // Mark job as pending
50 await ctx.runMutation(updateRemoveBgInternal, {
51 removeBgJobId: args.removeBgJobId,
52 status: "pending",
53 });
54
55 const runpodApiKey = process.env.RUNPOD_API_KEY!;
56 const runpodEndpointId = process.env.RUNPOD_REMOVE_BG_ENDPOINT_ID!;
57
58 const response = await fetch(
59 `https://api.runpod.ai/v2/${runpodEndpointId}/run`,
60 {
61 method: "POST",
62 headers: {
63 "Content-Type": "application/json",
64 Authorization: `Bearer ${runpodApiKey}`,
65 },
66 body: JSON.stringify({
67 input: {
68 remove_bg_job_id: args.removeBgJobId,
69 file_url: videoUrl,
70 },
71 }),
72 }
73 );
74
75 if (!response.ok) {
76 throw new Error(`RunPod API error: ${response.status}`);
77 }
78
79 const result = await response.json();
80 return { jobId: result.id };
81 },
82});
83Next, we'll write the mutations that the RunPod worker will use externally, to modify the database:
Writing the external mutations
External mutation: workerUpdateRemoveBg
This mutation is used by the RunPod worker to report progress back to Convex. It allows the worker to update the job's processing status and attach the resulting file once background removal is complete. Any UI subscribed to this job automatically reacts to these updates.
Mutation: workerGenerateUploadUrl
This mutation generates a secure, time-limited upload URL for Convex Storage. It allows external workers to upload processed files directly to Convex without needing database access or credentials.
1export const workerUpdateRemoveBg = mutation({
2 args: {
3 removeBgJobId: v.id("removeBgJobs"),
4 status: v.optional(
5 v.union(
6 v.literal("pending"),
7 v.literal("processing"),
8 v.literal("completed"),
9 v.literal("failed")
10 )
11 ),
12 resultId: v.optional(v.id("_storage")),
13 },
14 returns: v.null(),
15 handler: async (ctx, args) => {
16 const updateData: {
17 status?: "pending" | "processing" | "completed" | "failed";
18 resultId?: Id<"_storage">;
19 } = {};
20
21 if (args.status !== undefined) {
22 updateData.status = args.status;
23 }
24
25 if (args.resultId !== undefined) {
26 updateData.resultId = args.resultId;
27 }
28
29 await ctx.db.patch(args.removeBgJobId, updateData);
30 return null;
31 },
32});
33
34export const workerGenerateUploadUrl = mutation({
35 args: {},
36 returns: v.string(),
37 handler: async (ctx) => {
38 return await ctx.storage.generateUploadUrl();
39 },
40});
41Great, our Convex functions are ready. Let's start writing the RunPod handler in Python to utilize these convex functions.
If you're new to RunPod, take a look at this tutorial from Runpod on creating a serverless worker.
Once you've done with setting up the worker, we can start adding our code to handle the Convex function calls.
RunPod handler (Python worker)
This handler implements the actual background-removal workflow.
It downloads the input media, performs background removal, uploads the processed output to Convex Storage, and updates job status at each stage.
By calling Convex mutations from the worker, job progress is reflected in the UI in real time.
Let's start by installing the convex Python package (https://pypi.org/project/convex/), and add it to requirements.txt
1pip install convex
21import os
2import requests
3import runpod
4from convex import ConvexClient
5
6CONVEX_URL = os.environ["CONVEX_URL"]
7client = ConvexClient(CONVEX_URL)
8
9def download_video(url: str, save_path: str):
10 response = requests.get(url, timeout=(5, 60), stream=True)
11 response.raise_for_status()
12
13 with open(save_path, "wb") as f:
14 for chunk in response.iter_content(chunk_size=8192):
15 f.write(chunk)
16
17def upload_video_to_convex(upload_url: str, file_path: str) -> str:
18 with open(file_path, "rb") as f:
19 response = requests.post(
20 upload_url,
21 headers={"Content-Type": "application/octet-stream"},
22 data=f,
23 )
24 response.raise_for_status()
25 return response.json()["storageId"]
26
27def generate_upload_url() -> str:
28 return client.mutation("workerGenerateUploadUrl", {})
29
30def update_remove_bg_status(
31 remove_bg_job_id: str,
32 status: str,
33 result_id: str | None = None,
34):
35 client.mutation(
36 "workerUpdateRemoveBg",
37 {
38 "removeBgJobId": remove_bg_job_id,
39 "status": status,
40 "resultId": result_id,
41 },
42 )
43
44def handler(event):
45 remove_bg_job_id = event["input"]["remove_bg_job_id"]
46 file_url = event["input"]["file_url"]
47
48 input_path = "input.mp4"
49
50 update_remove_bg_status(remove_bg_job_id, "processing")
51
52 download_video(file_url, input_path)
53
54 # Your background removal logic here
55 result_path = remove_background(input_path)
56
57 upload_url = generate_upload_url()
58 result_id = upload_video_to_convex(upload_url, result_path)
59
60 update_remove_bg_status(remove_bg_job_id, "completed", result_id)
61
62 return {
63 "storageId": result_id,
64 "status": "completed",
65 }
66
67if __name__ == "__main__":
68 runpod.serverless.start({"handler": handler})
69This handler implements the actual background-removal workflow.
It downloads the input media, performs background removal, uploads the processed output to Convex Storage, and updates job status at each stage.
Let's break down all the functions used in the handler
Initializing the Convex Python client
1from convex import ConvexClient
2
3CONVEX_URL = os.environ["CONVEX_URL"]
4client = ConvexClient(CONVEX_URL)
5The convex Python package gives us a ConvexClient that connects directly to your Convex deployment. You pass it your deployment URL (stored as an environment variable on RunPod), and from that point on, you can call any Convex mutation or query straight from Python — no REST wrappers, no custom HTTP calls. This single client instance is reused across all the helper functions below.
download_video(url, save_path)
1def download_video(url: str, save_path: str):
2 response = requests.get(url, timeout=(5, 60), stream=True)
3 response.raise_for_status()
4 with open(save_path, "wb") as f:
5 for chunk in response.iter_content(chunk_size=8192):
6 f.write(chunk)
7This function takes the public URL that Convex generated for the uploaded file and streams it down to the GPU instance. We use chunked streaming so we don't blow up memory on large video files. Nothing Convex-specific here — it's just downloading the file that Convex Storage is serving.
generate_upload_url()
1def generate_upload_url() -> str:
2 return client.mutation("workerGenerateUploadUrl", {})
3This is where the Convex Python client shines. Instead of building an HTTP request manually, we just call client.mutation() with the name of the mutation we defined earlier in our Convex backend. This calls workerGenerateUploadUrl, which returns a secure, time-limited upload URL from Convex Storage. The worker can then POST the processed file directly to that URL — no database credentials needed, no complex auth flow.
upload_video_to_convex(upload_url, file_path)
1def upload_video_to_convex(upload_url: str, file_path: str) -> str:
2 with open(file_path, "rb") as f:
3 response = requests.post(
4 upload_url,
5 headers={"Content-Type": "application/octet-stream"},
6 data=f,
7 )
8 response.raise_for_status()
9 return response.json()["storageId"]
10Once we have the upload URL from generate_upload_url(), we POST the processed video file to it. Convex responds with a storageId — this is the ID we'll use to link the result back to the job in the database. The upload URL handles all the storage plumbing; the worker just sends raw bytes.
update_remove_bg_status(remove_bg_job_id, status, result_id)
1def update_remove_bg_status(
2 remove_bg_job_id: str,
3 status: str,
4 result_id: str | None = None,
5):
6 client.mutation(
7 "workerUpdateRemoveBg",
8 {
9 "removeBgJobId": remove_bg_job_id,
10 "status": status,
11 "resultId": result_id,
12 },
13 )
14This is the callback function that keeps the UI in sync. Every time the worker hits a new stage, it calls client.mutation("workerUpdateRemoveBg", ...) to update the job's status in the Convex database. Because Convex queries are live, the moment this mutation runs, any UI subscribed to that job's data gets the update pushed to it automatically. The worker calls this twice: once to set "processing" when it starts, and once to set "completed" with the resultId when it's done.
handler(event) — Putting it all together
1def handler(event):
2 remove_bg_job_id = event["input"]["remove_bg_job_id"]
3 file_url = event["input"]["file_url"]
4 input_path = "input.mp4"
5
6 update_remove_bg_status(remove_bg_job_id, "processing")
7 download_video(file_url, input_path)
8 result_path = remove_background(input_path)
9 upload_url = generate_upload_url()
10 result_id = upload_video_to_convex(upload_url, result_path)
11 update_remove_bg_status(remove_bg_job_id, "completed", result_id)
12
13 return {"storageId": result_id, "status": "completed"}
14The main handler ties everything together. RunPod passes in the event payload (which contains the job ID and file URL that Convex sent). The handler then:
- Marks the job as
"processing"via Convex mutation - Downloads the video from Convex Storage
- Runs the background removal model
- Requests an upload URL from Convex
- Uploads the processed result back to Convex Storage
- Marks the job as
"completed"with the newstorageId
The key takeaway: the Convex Python client lets the GPU worker participate in the same data flow as your frontend. Mutations called from Python behave exactly like mutations called from your Next.js app — they update the database and trigger live query refreshes for all connected clients.
With this, our background removal workflow implementation is now complete!
Deploying the serverless API on RunPod
Now that our handler code is ready, let's deploy it as a serverless endpoint on RunPod.
Step 1: Create a serverless endpoint
Go to RunPod > Serverless > Import from Github
Select the branch and the location of docker file and then configure the environment variables, most importantly CONVEX_URL
RunPod serverless endpoint creation from GitHub
Step 2: Configure the serverless endpoint
Pick your GPU type, set the max/min workers, and configure the idle timeout. The idle timeout controls how quickly workers scale down to zero when there are no jobs.
RunPod endpoint configuration panel
Step 3: Grab your endpoint ID and API key
Once the endpoint is created, copy the Endpoint ID from the dashboard. You'll also need your RunPod API Key from your account settings.
Add both to your Convex environment variables:
RUNPOD_API_KEY— your RunPod API keyRUNPOD_REMOVE_BG_ENDPOINT_ID— the endpoint ID you just copied
Step 4: Test the endpoint
You can test the endpoint directly from the RunPod dashboard using the "Run" tab, or trigger it from your app by uploading a file.
Once triggered, you should see the job appear in your RunPod logs, and simultaneously see the status update from pending > processing > completed in your Convex dashboard as the worker calls back into your mutations.
With this, our background removal workflow implementation is now complete!
Wrapping up
The pattern we built here — Convex action triggers GPU job, GPU worker calls back into Convex mutations, UI updates in real time — isn't limited to background removal. You can swap in any GPU-intensive task: video compression, style transfer, transcription, object detection, or whatever open-source model fits your use case.
What makes this work well is that the GPU worker isn't a black box. It participates in the same reactive data flow as your frontend. Every status update, every uploaded result, flows through Convex mutations and gets pushed to connected clients instantly. No polling, no webhooks to manage, no separate status-checking infrastructure.
If you're building something that needs heavy compute but doesn't justify a dedicated GPU server, this Convex + RunPod combo is worth trying. Fork the repo, swap in your own model, and you're up and running.
Convex is the backend platform with everything you need to build your full-stack AI project. Cloud functions, a database, file storage, scheduling, workflow, vector search, and realtime updates fit together seamlessly.