Skip to content

Background Jobs

Convex has no traditional job queue. Background jobs are built from two primitives: a tracking table for status and a scheduler for deferred execution. The client subscribes reactively to the tracking table and sees updates in real time.

Client Convex Mutation Convex Action
| | |
|-- call mutation --------->| |
| |-- insert job (pending) |
| |-- scheduler.runAfter(0, ...) |
|<-- return jobId ----------| |
| | |
|-- useQuery(job, {jobId})->| scheduled fn runs ----->|
| (reactive subscription) | update job status ----->|
|<-- re-render (progress) --| |
|<-- re-render (complete) --| |

The mutation inserts a job record and schedules the work atomically. If either fails, both roll back. The client uses useQuery to subscribe to the job record, and Convex pushes status changes in real time.

MethodDescription
ctx.scheduler.runAfter(delayMs, fnRef, args)Schedule a function after a delay. 0 = immediately after the current transaction commits.
ctx.scheduler.runAt(timestamp, fnRef, args)Schedule a function at a specific Unix timestamp (ms).
ctx.scheduler.cancel(scheduledFnId)Cancel a previously scheduled function.

Execution guarantees:

  • Scheduled mutations execute exactly once. Convex retries on internal errors.
  • Scheduled actions execute at most once. They are not retried automatically because side effects may not be idempotent.

Both GitHub and Atlassian webhooks follow the same durable event buffer pattern. This is the most common background job pattern in Foundry.

  1. Validate HMAC signature using constant-time comparison (crypto.subtle.timingSafeEqual).

  2. Store raw event in a buffer table (sourceControlEvents or atlassianWebhookEvents) with status: "pending".

  3. Schedule async processing via ctx.scheduler.runAfter(0, processorAction) for immediate execution after the HTTP response returns.

  4. Return 200 OK within the HTTP response window. The webhook sender sees acknowledgment regardless of processing time.

  5. Process the event in the scheduled action. Route by event type, write to domain tables, emit activity events.

  6. Handle failures by queueing to a retry table with exponential backoff.

// convex/http.ts — webhook handler
http.route({
path: "/api/webhooks/github",
method: "POST",
handler: httpAction(async (ctx, request) => {
const signature = request.headers.get("x-hub-signature-256");
const body = await request.text();
if (!verifySignature(body, signature)) {
return new Response("Invalid signature", { status: 401 });
}
const eventId = await ctx.runMutation(
internal.sourceControl.storeEvent,
{ payload: body, status: "pending" }
);
await ctx.scheduler.runAfter(0,
internal.sourceControl.processEvent,
{ eventId }
);
return new Response("OK", { status: 200 });
}),
});

Foundry processes 8 webhook endpoints using this pattern:

EndpointSourceEvents
POST /clerk-users-webhookClerkUser/org membership changes
POST /api/webhooks/githubGitHubPush, PR, issues, reviews, deployments
POST /api/webhooks/jiraAtlassianJira issue events
POST /api/webhooks/confluenceAtlassianConfluence page events
POST /api/webhooks/stripeStripeInvoice/subscription events
POST /api/sandbox/hook-eventsSandboxClaude Code tool use events
POST /api/sandbox/completionSandboxSession completion signals
POST /api/sandbox/tail-telemetrySandboxCloudflare Tail Worker metrics

Convex does not automatically retry actions. For operations that call external services, implement manual retry logic.

export const executeWithRetry = internalAction({
args: {
jobId: v.id("backgroundJobs"),
attempt: v.optional(v.number()),
},
handler: async (ctx, { jobId, attempt = 1 }) => {
const MAX_ATTEMPTS = 5;
try {
const response = await fetch("https://api.example.com/work");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await ctx.runMutation(internal.backgroundJobs.markComplete, {
jobId, output: await response.json(),
});
} catch (error) {
if (attempt < MAX_ATTEMPTS) {
// Exponential backoff with jitter
const baseDelay = Math.pow(2, attempt) * 1000;
const jitter = Math.random() * 1000;
await ctx.scheduler.runAfter(
baseDelay + jitter,
internal.backgroundJobs.executeWithRetry,
{ jobId, attempt: attempt + 1 }
);
} else {
await ctx.runMutation(internal.backgroundJobs.markFailed, {
jobId,
reason: `Failed after ${MAX_ATTEMPTS} attempts`,
});
}
}
},
});

Foundry uses this pattern for source control webhook retries with a dedicated sourceControlRetryQueue table: up to 5 attempts, exponential backoff capped at 1 hour.

Multiple scheduled functions (timeout, retry, main work, cancellation) may race to update the same job. Always check the current status before writing.

export const updateStatus = internalMutation({
args: { jobId: v.id("backgroundJobs"), result: v.any() },
handler: async (ctx, { jobId, result }) => {
const job = await ctx.db.get(jobId);
if (!job) return;
// Don't overwrite terminal states
if (
job.result.status === "completed" ||
job.result.status === "failed" ||
job.result.status === "canceled"
) {
return;
}
await ctx.db.patch(jobId, { result });
},
});

Schedule a timeout check alongside the job. If the job is still running when the timeout fires, mark it as failed.

// When starting the job:
await ctx.scheduler.runAfter(
5 * 60 * 1000, // 5-minute timeout
internal.backgroundJobs.checkTimeout,
{ jobId }
);

Cancel a scheduled function before it runs, or mark the job as canceled so the action checks and exits early.

export const cancel = mutation({
args: { jobId: v.id("backgroundJobs") },
handler: async (ctx, { jobId }) => {
const job = await ctx.db.get(jobId);
if (!job) throw new Error("Job not found");
if (job.result.status === "completed" || job.result.status === "failed") {
return { canceled: false };
}
if (job.scheduledFnId) {
await ctx.scheduler.cancel(job.scheduledFnId);
}
await ctx.db.patch(jobId, { result: { status: "canceled" } });
return { canceled: true };
},
});

For recurring tasks, define cron jobs in convex/crons.ts. Foundry uses daily crons for health scoring, digest cache invalidation, model catalog refresh, and reconciliation.

convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.cron(
"daily health scores",
"0 6 * * *", // 6:00 AM UTC
internal.aiHealthScores.computeDaily,
);
crons.cron(
"model catalog refresh",
"0 0 * * *", // midnight UTC
internal.ai.refreshModelCache,
);
crons.interval(
"sandbox queue drain",
{ seconds: 30 },
internal.sandbox.drainQueue,
);
export default crons;

Cron functions must be internal or public and cannot accept dynamic arguments.

  1. Do not call external APIs from mutations. Mutations retry on conflicts, so side effects would execute multiple times. Use actions.
  2. Do not use .filter() on job queries. Define indexes in schema.ts and use .withIndex().
  3. Do not await the action from the start mutation. The mutation should insert + schedule + return. Awaiting defeats the purpose.
  4. Actions cannot write to ctx.db directly. They must call ctx.runMutation() to persist data.
  5. Do not retry without backoff. Immediate retry loops overwhelm external services.