Jashwanth Peddisetty's avatar
Jashwanth Peddisetty
38 minutes ago

How to connect Convex to RunPod for serverless GPU workloads

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 workflowArchitecture 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:

https://github.com/srinivastls/Fg-bg-Separator

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 outputConvex 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 resultFile upload feature prompt result

We then run the application using the command.

1npx convex dev
2

Running file upload websiteRunning 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})
11

This 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 overviewConvex 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});
83

Next, 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});
41

Great, 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
2
1import 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})
69

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.

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)
5

The 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)
7

This 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", {})
3

This 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"]
10

Once 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    )
14

This 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"}
14

The 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:

  1. Marks the job as "processing" via Convex mutation
  2. Downloads the video from Convex Storage
  3. Runs the background removal model
  4. Requests an upload URL from Convex
  5. Uploads the processed result back to Convex Storage
  6. Marks the job as "completed" with the new storageId

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 GitHubRunPod 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 panelRunPod 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 key
  • RUNPOD_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.

Build in minutes, scale forever.

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.

Get started