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
Terminalelements create job send-welcome -fields='to: string, name: string'
app/jobs/send-welcome.tsimport { Job, email } from "@elements/app"; import WelcomeEmail from "#app/emails/welcome"; export interface SendWelcomeJobFields { to: string; name: string; } export class SendWelcomeJob extends Job<SendWelcomeJobFields> { 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:
app/pages/signup/services.tsnew SendWelcomeJob({ to: user.email, name: user.name, }).schedule();
Pass a time in plain words to run it later:
app/pages/signup/services.tsnew 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:
app/pages/signup/services.tsimport { 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:
Terminalelements 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 (opens in a new tab), a CRM that emails each rep their tasks every morning.
- Letterhearth (opens in a new tab), a paid newsletter with scheduled sends.
The manual: jobs (opens in a new tab), email (opens in a new tab), database (opens in a new tab).