Skip to content

Digging Deeper

Background Jobs

This page shows you how to move slow work out of the request cycle. You’ll define a job class, enqueue it from a controller, drain the queue with the wheels jobs work worker (or processQueue() on a schedule), configure retries with exponential backoff, and route urgent work through a high-priority queue.

You’ll learn:

  • How to define a job by extending wheels.Job and implementing perform()
  • How to enqueue jobs immediately, after a delay, or at a specific time
  • How to drain queues with the wheels jobs work worker, or processQueue() from a scheduled task
  • How retries and backoff work, and how to tune them
  • How priority queues let critical work jump the line
  • How to monitor, retry, and purge jobs in production
  • How to test perform() in isolation

A job is a CFC in app/jobs/ that extends wheels.Job. Override config() to set queue and retry options, and override perform() with the work itself. The base class handles enqueueing, locking, retries, and persistence.

app/jobs/SendWelcomeEmailJob.cfc
component extends="wheels.Job" {
function config() {
super.config();
this.queue = "mailers";
this.maxRetries = 5;
}
public void function perform(struct data = {}) {
user = model("User").findByKey(arguments.data.userId);
new app.mailers.UserMailer().sendWelcome(user);
}
}

UserMailer is the mailer from Sending Email. A job is not a controller, so sendEmail() isn’t available inside perform(); send mail through a mailer, which builds the controller instance sendEmail() needs. model() is available: the base class delegates it to application.wo.model().

Three rules:

  1. Extend wheels.Job. The base class in vendor/wheels/Job.cfc provides enqueue, enqueueIn, enqueueAt, processQueue, and the retry machinery.
  2. Override perform(struct data). This is where the work happens. Read inputs from arguments.data. Return nothing — the framework inspects success by whether an exception was thrown.
  3. Call super.config() first, then set this.queue, this.maxRetries, this.priority, this.baseDelay, this.maxDelay as needed.

Keep data small. Pass IDs, not whole model instances — the struct is serialized as JSON into a TEXT column, and a stale serialized snapshot is worse than a fresh findByKey() lookup at process time.

Instantiate the job and call one of three enqueue methods. The typical call site is a controller action (after create() succeeds) or a model afterCreate callback.

app/controllers/Users.cfc (fragment)
component extends="Controller" {
function create() {
user = model("User").new(params.user);
if (user.save()) {
// Immediate — runs on the next processQueue() poll
job = new app.jobs.SendWelcomeEmailJob();
job.enqueue(data={userId: user.id});
// Delayed — a gentle follow-up five minutes later
followup = new app.jobs.SendFollowupEmailJob();
followup.enqueueIn(seconds=300, data={userId: user.id});
// Scheduled — onboarding drip at a specific time
drip = new app.jobs.SendDripEmailJob();
drip.enqueueAt(runAt=DateAdd("d", 1, Now()), data={userId: user.id});
redirectTo(route="user", key=user.id);
} else {
renderView(action="new");
}
}
}

enqueue(), enqueueIn(), and enqueueAt() all return a struct with the persisted row’s id. The controller keeps responding fast — the actual email send happens when the next processQueue() poll picks the row up.

When several servers (or several requests) may enqueue the same piece of work, give it a uniqueKey. Only one job is written per key:

result = new app.jobs.DailyReportJob().enqueue(
data = {day: "2026-10-12"},
uniqueKey = "daily-report:2026-10-12"
);
if (result.duplicate) {
// Already enqueued by someone else: result.id is that job's id.
}

enqueueIn() and enqueueAt() take uniqueKey too. A first enqueue returns enqueued: true, duplicate: false. One whose key is already taken writes nothing and returns {enqueued: false, duplicate: true, status: "duplicate"} with the existing job’s id; it doesn’t throw. That holds for two enqueues racing each other as well: the database’s unique index decides, and the loser is reported as a duplicate.

  • A key stays taken while its row exists, whatever its status: a completed or failed job still holds it until the row is purged. Build the key from what makes the work unique, such as a date or a record id.
  • Keys can be up to 255 characters; a longer one throws Wheels.Job.InvalidUniqueKey. Hash a longer natural key (Hash(longKey, "SHA-256")).
  • Without a uniqueKey nothing changes: each job gets its own id as its key.
  • For a job enqueued with transactional=false, or inside a transaction on another datasource, the row is written when the transaction ends, so enqueue() returns {status: "deferred", enqueued: true} before the key is checked. If the key turns out to be taken at that point, the write is skipped and logged to wheels_jobs.
  • Inside a transaction on the job store’s own datasource, an enqueue that loses a race to the same key can’t always be told apart from a failure. On PostgreSQL the failed INSERT aborts the transaction, and on MySQL the re-read sees the transaction’s own snapshot. It then surfaces as Wheels.Job.EnqueueFailed rather than duplicate: true. The common duplicate, a key already committed before the enqueue, is still found before the INSERT and reported as a duplicate.
  • The key needs the table’s uniqueKey column and its unique index, which are added for you (see The jobs table). If they can’t be added, enqueue(uniqueKey=...) throws Wheels.Job.UniqueKeyUnavailable rather than writing a duplicate. Jobs without a key still enqueue. An enqueue inside a transaction never adds them itself, because DDL would commit the transaction on MySQL and Oracle; a worker poll or an enqueue outside a transaction does it.

wheels jobs enqueue adds a job without writing code: to kick one off by hand, re-run a one-off task, or enqueue from a deploy script. It needs this app’s server running, started with wheels start.

Terminal window
wheels jobs enqueue SendWelcomeEmailJob --data='{"userId":42}' # run now
wheels jobs enqueue SendFollowupEmailJob --in=300 # run in 5 minutes
wheels jobs enqueue SendDripEmailJob --at=2026-10-06T09:00:00Z # run at a time
wheels jobs enqueue billing.InvoiceJob --queue=billing --priority=5
Argument / flagDescription
<JobName>The job class under app/jobs/: SendWelcomeEmailJob, or billing.InvoiceJob for app/jobs/billing/InvoiceJob.cfc.
--dataThe data passed to perform(), as a JSON object.
--queue, --priorityOverride the job’s own this.queue and this.priority.
--inRun it after this many seconds.
--atRun it at this time, in ISO 8601: with Z or an offset (2026-10-06T11:00:00+02:00), or without one for this machine’s local time (2026-10-06 09:00). A time in the past is refused.
--format=jsonPrint the enqueued job as JSON.

“Scheduling” here means one delayed run (--in / --at, the same as enqueueIn() / enqueueAt()). Wheels has no recurring, cron-style schedule; for that, run wheels jobs enqueue or processQueue() from your system’s scheduler.

The class must be a component under app/jobs/ (or a jobClassPrefixes path, the same list the worker accepts) that extends wheels.Job. Anything else is refused before it’s loaded, and an unknown name lists the job classes it found. Enqueueing writes a row, so like wheels jobs work it only talks to this project’s own server and sends the reload password.

enqueue() writes its wheels_jobs row through the app’s datasource, so a job enqueued inside a transaction commits or rolls back with the rest of it. That makes a transactional outbox simple: save the data and enqueue the work in one model method, and run it with invokeWithTransaction().

app/models/User.cfc (fragment)
component extends="Model" {
public boolean function signupWithWelcome(required string email, required string firstName) {
var user = model("User").create(email = arguments.email, firstName = arguments.firstName);
var job = new app.jobs.SendWelcomeEmailJob();
job.enqueue(data = {userId: user.id});
return true;
}
}
app/controllers/Signups.cfc (fragment)
component extends="Controller" {
function create() {
// If signupWithWelcome() throws, neither the user nor the job is saved.
model("User").new().invokeWithTransaction(
method = "signupWithWelcome",
email = params.email,
firstName = params.firstName
);
redirectTo(route = "root");
}
}

The other side of this: work that must happen even when the transaction rolls back, such as a failure notice, can’t be enqueued inside it. Enqueue it after the transaction ends.

Under wheels.middleware.TenantResolver, the tenant’s work runs on the tenant datasource while the job store stays on the application’s. A job enqueued inside a Wheels-managed transaction (a model save, or invokeWithTransaction()) then is written when that transaction commits, and dropped if it rolls back.

  • enqueue() returns {status: "deferred", deferred: true, persisted: false}, with the job’s id.
  • The job still runs against the tenant’s datasource.
  • The same applies to a model with its own dataSource() that isn’t the application’s.
  • A raw transaction {} block isn’t tracked by Wheels. Under a tenant datasource, enqueue after the block ends, or do the work through a model save or invokeWithTransaction().

When a job can’t be written, the enqueue methods throw Wheels.Job.EnqueueFailed. If an enqueue is best-effort, catch that type.

Some work has to happen because a transaction failed: a failure notice to an editor, an audit record of the attempt, a retry-later job queued from inside a batch that’s about to be undone. A job that joins the transaction would be rolled back with it, so pass transactional=false:

// inside a model save or invokeWithTransaction()
new app.jobs.ImportFailedNoticeJob().enqueue(
data = {importId: importId},
transactional = false
);

Or make it the default for a job class, in its config():

component extends="wheels.Job" {
function config() {
this.transactional = false;
}
}

The job is then written after the outermost Wheels-managed transaction ends, whether it commits or rolls back. That includes a rollback caused by an exception and a savepoint unit that rolls back. enqueue() returns {status: "deferred", deferred: true} until then. With no transaction open, the job is written immediately, as usual. Leave the default (transactional=true) for jobs that should only run when the data commits.

Two limitations:

  • A raw transaction {} block isn’t tracked by Wheels. A transactional=false job enqueued inside one is written inside it and shares its fate. Use a model save or invokeWithTransaction(), or enqueue after the block.
  • A request that ends with abort inside the transaction may not write the job. The transaction is rolled back, but the request ends before the deferred write runs. Enqueueing earlier in the same transaction doesn’t help: the job is still deferred to the end. For a job that must survive an abort, enqueue it outside the transaction.

The simplest way to drain the queue is the bundled worker — a long-lived CLI process that polls the wheels_jobs table, claims one pending job per cycle, and runs it. It talks to your running app server, so start that first (wheels start):

your shell
wheels jobs work # process all queues; loops until Ctrl-C
wheels jobs work --queue=mailers # only drain the mailers queue
wheels jobs work --queue=mailers --interval=3 # poll every 3 seconds when idle (default 5)
wheels jobs work --stop-when-empty # exit once no job is ready to run — one-shot batches from cron or CI
wheels jobs work --max-jobs=100 # stop after 100 jobs (combine with --stop-when-empty to cap a batch)
wheels jobs work --quiet # suppress per-job output; failures still print

In production, run the worker under a process supervisor (systemd, Docker’s restart policy, Kamal accessory containers): when it loses the server it exits non-zero and the supervisor restarts it. Run several workers for parallelism — the framework uses optimistic row locking, so two workers never run the same job twice. --queue accepts a comma-delimited list (--queue=critical,default); jobs across the listed queues run in priority DESC, runAt ASC order.

Check queue health without tailing logs:

your shell
wheels jobs status # per-queue pending / processing / completed / failed table
wheels jobs status --queue=mailers # one queue only
wheels jobs status --format=json # machine-readable, for CI smoke tests or metric scrapes

You don’t need a separate worker process, though. Processing is a poll: processQueue() picks up every pending job whose runAt has arrived, runs each one, and returns counts. Call it from anything that runs on a schedule — a CFML scheduled task, a cron-invoked script, or a controller action behind an admin route that your scheduler hits:

app/controllers/admin/Jobs.cfc (fragment)
component extends="Controller" {
function drain() {
worker = new wheels.Job();
result = worker.processQueue(queue="mailers", limit=10);
// result = {processed: n, failed: n, skipped: n, errors: [...]}
renderWith(data=result);
}
}
  • processQueue() — process all queues, up to 10 jobs per call (the default limit).
  • processQueue(queue="mailers") — only drain the mailers queue.
  • processQueue(limit=100) — bigger batch for one-shot catch-ups.

In production, schedule the call at whatever cadence matches your latency tolerance — every minute is plenty for emails. Run it from multiple schedulers for parallelism: the base class uses optimistic row locking, so two pollers won’t process the same job twice (a row claimed between the SELECT and the claim shows up in the skipped count).

Check queue health without tailing logs — queueStats() returns per-status counts:

queue health
component extends="Controller" {
function status() {
jobs = new wheels.Job();
stats = jobs.queueStats(); // all queues
mailerStats = jobs.queueStats(queue="mailers"); // one queue
// {pending: n, processing: n, completed: n, failed: n, total: n}
renderWith(data=stats);
}
}

When perform() throws, the framework reschedules the job with exponential backoff until maxRetries is exhausted, at which point the row moves to status='failed' and stops being picked up.

app/jobs/ChargeCardJob.cfc
component extends="wheels.Job" {
function config() {
super.config();
this.queue = "payments";
this.maxRetries = 5;
this.baseDelay = 2; // seconds
this.maxDelay = 3600; // cap at one hour
}
public void function perform(struct data = {}) {
order = model("Order").findByKey(arguments.data.orderId);
order.chargeCard(); // may throw on transient network errors
}
}

The backoff formula is Min(this.baseDelay * 2^attempt, this.maxDelay), where attempt is the number of the attempt that just failed (1 for the first run). maxRetries counts the retries after the first run, not the runs. With the example’s settings (maxRetries=5, baseDelay=2, maxDelay=3600), the job runs at most six times: the first run plus five retries at roughly 4s, 8s, 16s, 32s and 64s. The sixth failure moves it to failed. Transient failures resolve quickly, and maxDelay caps the gap so a job configured with many retries stops hammering the external service. (The framework default is maxRetries=3: the first run plus retries at 4s, 8s and 16s, so up to four runs.)

Because retries are automatic, your perform() must be idempotent. If the first attempt charged the card but failed to record the success, the second attempt must notice and skip. Store an idempotency key on the model and check it at the top of perform().

A worker claims a job by flipping its row to processing, then runs it. If that worker is killed — OOM, a kill -9, a lost host — mid-run, the row is stranded in processing: no worker owns it, but the pending-job scan skips it. Each job’s timeout (seconds, default 300, set in config()) bounds this. The claiming worker records its own timeout on the row, and on every poll — before claiming new work — a worker reaps rows that have sat in processing past a grace margin built from that recorded timeout (timeout + max(60s, timeout)), requeuing them for retry (the attempt counts against maxRetries) or marking them failed once retries are exhausted.

Recovery is scoped to the queues the poll serves and the requeue is an atomic, guarded UPDATE, so running several workers is safe: a worker never reaps a live job on a queue it doesn’t process, and two workers reaping the same queue can’t requeue or re-count the same job twice. Because the reap window comes from the claiming worker’s recorded timeout rather than the poller’s, workers with different timeout values can share a queue — a short-timeout worker won’t reap a job that a longer-timeout worker is still legitimately running. (A row claimed before this was added, or on a database where the claimTimeout column can’t be added, simply falls back to the polling worker’s timeout.) Because a reaped job may have partly run before its worker died, recovery is at-least-once — the same reason perform() must be idempotent.

A reaped worker isn’t always dead. It may be stalled (a long GC pause, a hung remote call) and finish after its job has been requeued and claimed again. To keep that late finish from overwriting the new attempt, every claim writes a fresh claimToken to the row (and the claiming host to claimedBy), and an attempt can only complete, retry or fail the job while the row still carries its own token. A late attempt’s result is discarded and logged to wheels_jobs as Wheels.Job.Fenced; processNext() returns fenced: true for it and processQueue() counts it under fenced. Its perform() still ran, so this guarantees one recorded outcome per attempt, not one execution.

To kick failed jobs back into the queue — after you’ve fixed the bug, say — call retryFailed():

retry failed jobs
component extends="Controller" {
function retryAll() {
worker = new wheels.Job();
worker.retryFailed(); // retry every failed job
worker.retryFailed(queue="mailers"); // only the mailers queue
redirectTo(back=true);
}
}

Retry resets attempts to zero, clears the stored error, and flips status back to pending — the next processQueue() poll picks the rows up.

Two levers control which work runs first. Within a single processQueue() call, jobs run in priority DESC, runAt ASC order — set this.priority in config() (higher first) or pass priority= at enqueue time and urgent rows jump the line. Across queues, drain them in the order you care about:

drain in priority order
component extends="Controller" {
function drain() {
worker = new wheels.Job();
worker.processQueue(queue="critical", limit=50);
worker.processQueue(queue="default", limit=20);
worker.processQueue(queue="low", limit=5);
renderText("ok");
}
}

Every pending job in critical (up to the limit) goes first, then default, then low. Queues are just string labels on the wheels_jobs.queue column — create as many as you need. A typical split:

  • critical — payment retries, password resets, anything user-visible.
  • default — welcome emails, webhooks, routine follow-ups.
  • low — nightly analytics rollups, cleanup, anything that can wait.

Per-job priority is the finer instrument; queue-based separation is simpler to reason about. Use queues for cadence, priority for jumping the line within one.

For headless metrics, scrape wheels jobs status --format=json from a cron job or a Prometheus textfile exporter. (An interactive wheels jobs monitor dashboard is a tracked follow-up in #3090.) The same numbers are available in-app via queueStats() — expose it from a small admin endpoint and point whatever you already use — Prometheus, Datadog, a custom scrape — at the JSON:

app/controllers/admin/Jobs.cfc (fragment)
component extends="Controller" {
function metrics() {
jobs = new wheels.Job();
renderWith(data=jobs.queueStats());
}
}

Alert on two signals: failed growing, and pending not shrinking between polls. The first means a job class is throwing past its retries; the second means nothing is draining the queue.

Jobs persist to a wheels_jobs table keyed by UUID. The base class auto-creates it the first time enqueue() or processQueue() runs — no migration needed. Columns: id, jobClass, queue, data (JSON), priority, status (pending/processing/completed/failed), attempts, maxRetries, claimTimeout, uniqueKey, lastError, runAt, completedAt, failedAt, createdAt, updatedAt. See vendor/wheels/Job.cfc ($ensureJobTable) for the exact schema — it adapts column types per database (MySQL, PostgreSQL, SQL Server, H2, SQLite, Oracle).

You never need to touch the table directly. Query it through queueStats():

programmatic stats
component extends="Controller" {
function dashboard() {
job = new wheels.Job();
stats = job.queueStats(); // all queues
mailerStats = job.queueStats(queue="mailers");
}
}

stats is a struct with pending, processing, completed, failed, and total.

uniqueKey is nullable, with a unique index named idx_wjobs_unique_key. The index is plain on every database except SQL Server, where it is filtered to non-NULL keys (WHERE uniqueKey IS NOT NULL), because SQL Server’s unique indexes allow only one NULL. The framework always writes a key, so a row you insert yourself without one is valid but isn’t deduplicated.

On a table created before 4.2, the first worker poll (wheels jobs work) or the first enqueue(uniqueKey=...) adds the column, sets uniqueKey = id on the existing rows, and builds the index. It runs under the migration lock, taken without waiting: if another instance holds it, the upgrade waits for a later call. Servers still on 4.1 keep enqueuing meanwhile; their rows get a NULL key, which never collides. If the database user can’t ALTER the table, run the change yourself: ALTER TABLE wheels_jobs ADD uniqueKey VARCHAR(255) (VARCHAR2 on Oracle), UPDATE wheels_jobs SET uniqueKey = id, then CREATE UNIQUE INDEX idx_wjobs_unique_key ON wheels_jobs (uniqueKey), adding WHERE uniqueKey IS NOT NULL on SQL Server. On a large table, do this in a maintenance window: the backfill touches every row, and it runs as one statement under the migration lock without renewing the lock’s lease, so it must finish within migrationLockLease (default 3600 seconds).

perform() is a plain public method that takes a struct. Don’t exercise the queue in a test — instantiate the job, call perform() with fixture data, and assert on what it does. SendWelcomeEmailJob returns nothing and sends mail, so build the email without delivering it (deliver = false, restored afterwards) and check that the job runs to the end:

tests/specs/jobs/SendWelcomeEmailJobSpec.cfc
component extends="wheels.WheelsTest" {
function run() {
describe("SendWelcomeEmailJob", () => {
it("finds the user and builds the welcome email", () => {
// Build emails without sending them; restore the default afterwards.
var defaults = application.wheels.functions.sendEmail;
var original = StructKeyExists(defaults, "deliver") ? defaults.deliver : true;
defaults.deliver = false;
try {
var testUser = model("User").create(email = "new@example.com", firstName = "New");
var job = new app.jobs.SendWelcomeEmailJob();
expect(() => job.perform(data = {userId: testUser.id})).notToThrow();
} finally {
defaults.deliver = original;
}
});
});
}
}

If the job can’t find the user or build the email, perform() throws and the spec fails. To check what the email says, test the mailer itself: with deliver = false, sendEmail() returns the email as a struct (to, subject, html or text).

App specs run in the environment your .env sets (WHEELS_ENV=development in a new app), not testing, so config/testing/settings.cfm doesn’t apply to them. To keep every spec from sending mail, set deliver = false for sendEmail in config/development/settings.cfm; that also stops your development server from sending mail (see Per-environment SMTP).

Testing perform() in isolation keeps the spec fast and avoids the complexity of polling a live queue. The queue itself is framework code — trust it.

Completed and failed rows accumulate forever unless you prune them. purgeCompleted() deletes completed rows older than a cutoff:

prune the table
component extends="Controller" {
function purge() {
worker = new wheels.Job();
worker.purgeCompleted(); // completed jobs older than 7 days
worker.purgeCompleted(days=30); // completed older than 30 days
worker.purgeCompleted(days=30, queue="mailers"); // one queue only
renderText("ok");
}
}

It only touches status='completed' rows — failed rows persist until you clear them yourself, which is deliberate: they’re your error log. Retry them with retryFailed() or delete them with your own SQL once you’ve extracted what you need from lastError.

Run the purge from a cron or scheduled task. A weekly purge of completed rows older than 30 days keeps the table small without losing recent history for debugging.

  • Enqueue from afterCreate callbacks to keep the controller response fast. The model saves, the callback drops a row into wheels_jobs, the response ships.
  • Use enqueueIn for rate limits. If a third-party API caps you at one call per second, stagger calls with enqueueIn(seconds=1), enqueueIn(seconds=2), and so on — cheaper than a token-bucket middleware for low-volume cases.
  • One job per side effect. Don’t bundle email + Slack + webhook into a single perform() — if Slack is down, you don’t want to retry the email and the webhook. Enqueue three separate jobs.
  • Make perform() idempotent. Every job will, eventually, run twice. Plan for it: check-before-write, store idempotency keys, use unique database constraints.
  • Pass IDs, not objects. data={userId: user.id} beats data={user: user}. The fresh findByKey() in perform() sees the current state, not a snapshot from when you enqueued.