Delayed Jobs
Jobs that should not run immediately
Some jobs need to wait. “Send a follow-up email 24 hours after the order.” “Expire the password reset token in 1 hour.” “Retry the payment in 30 minutes.” These are delayed jobs — they sit in the queue until their scheduled time arrives.
The scheduled_at column
The jobs table already has a scheduled_at column. The dequeue query already checks it:
WHERE status = 'pending' AND scheduled_at <= datetime('now') A job with scheduled_at in the future is invisible to the worker — it will not be picked up until the time arrives. This is all that delayed jobs need. No new tables, no new queries.
Enqueueing delayed jobs
Use the scheduledAt option in enqueue:
// Send a follow-up email in 24 hours
enqueue(
"send_followup_email",
{ orderId: order.id },
{
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
},
);
// Expire a password reset token in 1 hour
enqueue(
"expire_reset_token",
{ tokenId: token.id },
{
scheduledAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
},
);
// Retry a payment in 30 minutes
enqueue(
"retry_payment",
{ orderId: order.id },
{
scheduledAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
},
); The job is inserted with status "pending" and a future scheduled_at. The worker polls every second, but this job will not appear in the dequeue results until the scheduled time.
A helper for delays
export function enqueueDelayed(
type: string,
payload: Record<string, unknown>,
delayMs: number,
options: { priority?: number; maxAttempts?: number } = {},
): string {
return enqueue(type, payload, {
...options,
scheduledAt: new Date(Date.now() + delayMs).toISOString(),
});
}
// Usage
enqueueDelayed("send_followup_email", { orderId: order.id }, 24 * 60 * 60 * 1000);
enqueueDelayed("expire_reset_token", { tokenId: token.id }, 60 * 60 * 1000); How retries create delayed jobs
The Retries and Backoff lesson already uses delayed jobs. When a job fails and has retries remaining, failJob sets scheduled_at to a future time:
scheduled_at = datetime('now', '+' || (attempts + 1) * 30 || ' seconds') A failed job with 2 attempts retries in 90 seconds. It is a pending job with a future scheduled_at — the same mechanism as explicitly delayed jobs. The queue has one concept (scheduled_at) that serves two purposes (intentional delays and retry backoff).
Canceling delayed jobs
A user cancels their order. The follow-up email should not be sent:
export function cancelJob(jobId: string): boolean {
const result = db.prepare("DELETE FROM jobs WHERE id = ? AND status = 'pending'").run(jobId);
return result.changes > 0;
} Store the job ID when enqueueing so you can cancel later:
const followupJobId = enqueueDelayed("send_followup_email", { orderId: order.id }, 86400000);
// Store followupJobId on the order for cancellation
db.prepare("UPDATE orders SET followup_job_id = ? WHERE id = ?").run(followupJobId, order.id); Exercises
Exercise 1: Enqueue a delayed job with a 10-second delay. Verify the worker does not pick it up immediately. Wait 10 seconds. Verify it runs.
Exercise 2: Enqueue a delayed job and then cancel it before it runs. Verify it is deleted.
Exercise 3: Query all scheduled (future) jobs: SELECT * FROM jobs WHERE scheduled_at > datetime('now'). Verify your delayed jobs appear.
How does the queue know not to process a delayed job before its scheduled time?