--- title: "Jobs" author: "@chris" author_url: https://elements.dev/u/chris published: 2026-10-02T15:11:56.348Z url: https://elements.dev/feed/01a0fd2c-03bd-7ada-840c-f2c79e2dcca3 kind: lesson format: article --- # Jobs by [@chris](https://elements.dev/u/chris) ยท 2026-10-02 ## Description Hand slow work to background workers through a queue in your own database, with retries. Every app needs background jobs. Sending email, charging a card, building an export, calling a slow API: work like this cannot run while a visitor waits, and it cannot be lost when something fails. Like routing, it is not optional, so Elements provides it. Jobs are a producer and consumer system. Your code produces a job by putting it in a queue, and worker processes consume it. A job gives you three things: - **It runs in a separate worker process,** not in the app process serving your pages. The page that schedules a job answers at once, and slow work never holds up a request. - **It survives failures.** A job that throws runs again, with growing pauses between attempts. A worker that crashes partway through counts as a failed attempt, and the job is picked up again. - **Its state is tracked.** Every job is a row with its state: pending, running, completed, failed or cancelled, along with its attempts and its last error. A job that never succeeds stays in the table, marked failed, so you can see why. A pending job can be cancelled by its id. ## Built into your deployed app There is no queue service to sign up for, host or connect. The queue is a table in your app's own Postgres database, and the project server starts the workers for you, on your computer while you build and on every server you deploy to. Add workers to process more jobs at once. ## Write a job ```bash Terminal elements create job send-welcome -fields='to: string, name: string' ``` ```typescript app/jobs/send-welcome.ts import { Job, email } from "@elements/app"; import WelcomeEmail from "#app/emails/welcome"; export interface SendWelcomeJobFields { to: string; name: string; } export class SendWelcomeJob extends Job { static maxAttempts = 5; run() { let { to, name } = this.fields; email({ to, subject: "Welcome", body: new WelcomeEmail({ name }), }); } } ``` A job is a class with typed fields and a `run()` method. Its fields are saved with it, so any worker on any server can run it, at any time after it was scheduled. ## Schedule a job Call `schedule()` with no argument to run the job now. The next free worker picks it up: ```typescript app/pages/signup/services.ts new SendWelcomeJob({ to: user.email, name: user.name, }).schedule(); ``` Pass a time in plain words to run it later: ```typescript app/pages/signup/services.ts new SendFollowupJob({ userId: user.id }).schedule("in 3 days"); new RetryPaymentJob({ orderId }).schedule("in 1h"); new ExpirePromoJob({ promoId }).schedule("tomorrow at 8am"); new WeeklyCheckInJob({ userId: user.id }).schedule("next monday"); ``` `schedule()` returns the job's id, which `Job.cancel(id)` takes to cancel it while it is still pending. ## Scheduled together with your writes Because the queue is in your database, a job scheduled inside a transaction commits with the rest of your writes, or not at all: ```typescript app/pages/signup/services.ts import { tx } from "@elements/app"; import { SendWelcomeJob } from "#app/jobs/send-welcome"; /** @rpc */ export function signup(form: SignupForm): User { return tx(() => { let user = createUser(form); new SendWelcomeJob({ to: user.email, name: user.name, }).schedule(); createDefaultWorkspace(user); return user; }); } ``` If `createDefaultWorkspace` throws after the job was scheduled, the transaction rolls back and the job goes with it, so it never runs. A job scheduled in a transaction only exists once the transaction commits, so no worker ever runs work for data that was never saved. ## Retries When `run()` throws, the job runs again: 2 seconds later, then 4, then 8, doubling each time up to an hour, until it has tried `maxAttempts` times. `timeoutMs` caps how long one attempt can take, and a worker that goes past it is stopped and the job retried. See the failed jobs at any time: ```bash Terminal elements db -sql "select * from elements.jobs where state = 'failed' order by updated_at desc" ``` A retry runs `run()` again from the top. A job that must never repeat itself, like charging a card, sets `static maxAttempts = 1`. ## See jobs in a demo - [Dealwren](https://elements.dev/demos/01a0f399-f3bb-7c53-af59-10b9fa312a32), a CRM that emails each rep their tasks every morning. - [Letterhearth](https://elements.dev/demos/01a0f455-f25d-784c-a9d9-35a5841308ce), a paid newsletter with scheduled sends. The manual: [jobs](/learn/man/jobs), [email](/learn/man/email), [database](/learn/man/database).