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.
Core pattern
Section titled “Core pattern”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.
Scheduler API
Section titled “Scheduler API”| Method | Description |
|---|---|
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.
Webhook processing pattern
Section titled “Webhook processing pattern”Both GitHub and Atlassian webhooks follow the same durable event buffer pattern. This is the most common background job pattern in Foundry.
-
Validate HMAC signature using constant-time comparison (
crypto.subtle.timingSafeEqual). -
Store raw event in a buffer table (
sourceControlEventsoratlassianWebhookEvents) withstatus: "pending". -
Schedule async processing via
ctx.scheduler.runAfter(0, processorAction)for immediate execution after the HTTP response returns. -
Return
200 OKwithin the HTTP response window. The webhook sender sees acknowledgment regardless of processing time. -
Process the event in the scheduled action. Route by event type, write to domain tables, emit activity events.
-
Handle failures by queueing to a retry table with exponential backoff.
// convex/http.ts — webhook handlerhttp.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:
| Endpoint | Source | Events |
|---|---|---|
POST /clerk-users-webhook | Clerk | User/org membership changes |
POST /api/webhooks/github | GitHub | Push, PR, issues, reviews, deployments |
POST /api/webhooks/jira | Atlassian | Jira issue events |
POST /api/webhooks/confluence | Atlassian | Confluence page events |
POST /api/webhooks/stripe | Stripe | Invoice/subscription events |
POST /api/sandbox/hook-events | Sandbox | Claude Code tool use events |
POST /api/sandbox/completion | Sandbox | Session completion signals |
POST /api/sandbox/tail-telemetry | Sandbox | Cloudflare Tail Worker metrics |
Retry with exponential backoff
Section titled “Retry with exponential backoff”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.
Terminal state guards
Section titled “Terminal state guards”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 }); },});Timeouts
Section titled “Timeouts”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 });Cancellation
Section titled “Cancellation”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 }; },});Cron jobs
Section titled “Cron jobs”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.
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.
Common pitfalls
Section titled “Common pitfalls”- Do not call external APIs from mutations. Mutations retry on conflicts, so side effects would execute multiple times. Use actions.
- Do not use
.filter()on job queries. Define indexes inschema.tsand use.withIndex(). - Do not await the action from the start mutation. The mutation should insert + schedule + return. Awaiting defeats the purpose.
- Actions cannot write to
ctx.dbdirectly. They must callctx.runMutation()to persist data. - Do not retry without backoff. Immediate retry loops overwhelm external services.