Circle V2 uses Vercel Queues for async background jobs and Vercel Cron Jobs for scheduled tasks. Both are configured via apps/web/vercel.json.
Background jobs are built on the @repo/vqs package, which wraps Vercel Queues with deduplication, status tracking, and retry logic. See packages/vqs/README.md for the full API.
Producer Vercel Queue Consumer
───────── ──────────── ────────
job.performLater(params) --> topic: "sync-patient" --> POST /api/queue/sync-patient
└── createQueueHandler(SyncPatientJobbable)
└── job.runTracked(params, metadata)
createQueueHandlerapps/web/
shared/jobs/
sync-patient.jobbable.ts # Job class (extends Jobbable)
app/api/queue/
sync-patient/route.ts # Route handler (one per topic)
packages/vqs/ # Shared queue infrastructure
apps/web/vercel.json # Queue + cron config
apps/web/shared/jobs/<name>.jobbable.ts extending Jobbableapps/web/app/api/queue/<topic>/route.ts with createQueueHandlerexperimentalTriggers entry in apps/web/vercel.jsonSee the @repo/vqs README for a step-by-step example with code.
Failed jobs are retried with exponential backoff ((10 * attempt)^2 seconds) up to the maxAttempts defined in the Jobbable's config. After exhausting retries, the message is acknowledged and deleted.
All job executions are tracked in the job_executions database table with statuses: running, success, failed.
Cron jobs are Next.js GET route handlers that Vercel calls on a schedule.
Cron routes live under apps/web/app/api/cron/ and are registered in vercel.json:
{
"crons": [
{
"path": "/api/cron/sync-patients",
"schedule": "0 * * * *"
}
]
}
All cron routes must be wrapped with withCronSecret from @repo/auth-next/api:
// app/api/cron/sync-patients/route.ts
import { withCronSecret } from "@repo/auth-next/api";
export const GET = withCronSecret(async (_request: Request) => {
// Cron logic here -- typically enqueues jobs
return new Response("OK", { status: 200 });
});
withCronSecret validates the Authorization: Bearer <CRON_SECRET> header that Vercel sends automatically. In local dev (IS_LOCAL_DEV=true + localhost), the check is bypassed.
The typical pattern is for crons to enqueue jobs rather than doing heavy work inline. This gives you retry logic, status tracking, and timeout isolation for free:
export const GET = withCronSecret(async () => {
const job = new SyncPatientJobbable();
await job.performLater({ patientId: 123 });
return new Response("OK", { status: 200 });
});
apps/web/app/api/cron/<name>/route.ts with a GET handler wrapped in withCronSecretcrons entry in apps/web/vercel.json with the path and scheduleCRON_SECRET in your Vercel project environment variables (Vercel sends this automatically)Standard cron expressions:
| Expression | Meaning |
|---|---|
0 * * * * |
Every hour |
*/15 * * * * |
Every 15 minutes |
0 0 * * * |
Daily at midnight UTC |
0 9 * * 1 |
Every Monday at 9am UTC |
Vercel cron jobs run in UTC. Minimum interval on the Pro plan is 1 minute.