Elements vs Trigger.dev
Trigger.dev is a hosted background job service for TypeScript apps. You write tasks in your codebase with its SDK, deploy them to Trigger.dev separately from your app, and start them from your app with an API call. Trigger.dev runs them on its own machines, with retries, queues and schedules. The platform is open source and can be self-hosted. Its revenue is Trigger.dev Cloud, which bills compute by the second at a rate set by machine size, plus a charge per run.
Elements is not a job service. It is an integrated app environment for building web apps, built for people and their agents: one coherent system that includes a project server that runs while you work, a default app framework with background jobs and cron built in, a package installer, a test runner, a bundled Postgres database, and a command that deploys to any Ubuntu server you reach over SSH. Elements charges for that tooling, per machine, and meters nothing (pricing).
The difference is how many places your code runs. With Trigger.dev, your jobs are a second deploy, on another company's machines, with their own copy of your secrets and their own bill. An Elements app's jobs run in a worker process on the servers the app already runs on, and the worker deploys with the app. The queue is a table in your own Postgres, so scheduling a job commits or rolls back with the rest of your writes.
At a Glance
| Elements | Trigger.dev | |
|---|---|---|
| What it is | An integrated app environment with jobs and cron built in | A hosted service that runs your background tasks on its machines |
| What you pay for | Tooling, per machine, per month (pricing) | Compute by the second, priced by machine size, plus a charge per run (pricing) |
| Where jobs run | A worker process on your own servers | Containers on Trigger.dev's machines |
| Deploying jobs | Part of elements deploy, with the app |
A separate deploy that builds a container image |
| Your secrets | One set, on your servers | Also set in Trigger.dev's dashboard |
| Job saved in the same transaction as your data | Yes: it commits or rolls back with your other writes | No: starting a job is an API call to another system |
| Job history | Rows in your Postgres, queryable with SQL | Trigger.dev's dashboard, with log retention set by plan |
| Cron | One line in your app | Scheduled tasks, with the number capped by plan |
| Local development | Runs with your app, nothing extra to start | A dev command that connects to Trigger.dev |
Your Jobs Run Where Your App Runs
A background job reads and writes the same data as your app: it looks up the order, charges the card, marks it paid. Where the job runs decides how it reaches that data, and how many things you deploy.
Trigger.dev runs tasks in their own environment. npx trigger.dev deploy builds your task code into a container image and deploys it to Trigger.dev, apart from your app's own deploy. Because Trigger.dev runs the code, its docs say "any environment variables you use in your tasks need to be accessible to us", so your database credentials and API keys are set in Trigger.dev's dashboard as well as your host's.
In Elements, a job is a class in your app's app/jobs/ folder, and it deploys with the app. There is no second deploy for jobs. elements deploy installs the worker as its own service on every machine, next to the app and the load balancer. Before the new release goes live, it runs your tests on every machine and your migrations once, on the first machine (what deploy does). The worker runs against the same Postgres with the same config, and sql(), tx() and email() work in a job the same way they do in a server function (layout). You keep one codebase, one deploy and one set of secrets, and no other company runs your job code.
Trigger.dev's home page promises you will "never hit a timeout". Elements jobs never had a host timeout to escape: a job runs in a worker process on your own server, and each attempt runs as long as the timeoutMs you set (jobs).
The Queue Is a Table in Your Own Postgres
A job usually starts because of a write: a user signs up, an order is placed. The write and the job have to agree. If the order saves and the job is lost, the card is never charged; if the job runs and the order rolled back, the card is charged for nothing.
With Trigger.dev, triggering a task is a call to Trigger.dev's API, authenticated with TRIGGER_SECRET_KEY. That call goes to a different system from your database, so it cannot commit or roll back with your transaction.
In Elements, the queue is the elements.jobs table in your app's own Postgres. Calling schedule() inside tx() inserts the job row in the same transaction as your other writes. Workers are told about the job only when the transaction commits, and if it rolls back, no one ever sees the row (jobs):
import { tx, sql } from "@elements/app";
import { ChargeCardJob } from "#app/jobs/charge-card";
/** @rpc */
export function placeOrder(email: string, total: number) {
return tx(() => {
let order = sql<{ id: string }>(`
insert into orders (email, total)
values (${email}, ${total})
returning id
`).firstOrThrow();
new ChargeCardJob({ orderId: order.id }).schedule();
return order.id;
});
}
The order and the job commit together or not at all.
Elements Jobs Are Durable, in Your Own Database
Durable means a job survives failure: it is saved before it runs, retried when it fails, and never silently lost. Elements jobs are durable in your own Postgres. A job is a row before any worker touches it. A job that throws is retried with exponential backoff, 2 seconds after the first failure, then 4, then 8, up to its maxAttempts. A worker that crashes mid-job counts as a failed attempt, so the job runs again (jobs).
Trigger.dev shows run history in its dashboard, and how long it keeps logs depends on your plan. In Elements, failed jobs stay in elements.jobs forever by default, and successful ones for 24 hours, both configurable. Your job history is data in your own database, and you or your agent read it with the same SQL the app already uses:
select * from elements.jobs where state = 'failed' order by updated_at desc;
Durable jobs also need control: idempotencyKey keeps a job from being queued twice, priority moves urgent work to the front, and Job.cancel(id) withdraws a pending job (jobs).
Multi-Step Work Is a Chain of Jobs
Trigger.dev gives a task waits and subtasks: a task can pause for minutes or days, or wait for a child task to finish. On Trigger.dev Cloud, a paused task is checkpointed, its state saved and its machine released, so the wait does not use compute.
In Elements, each stage is its own job. A job saves its result and schedules the next job in one transaction, so the next stage is queued at the same moment the first stage's result is saved, and never before:
export class ConfirmOrderJob extends Job<{ orderId: string }> {
run() {
let { orderId } = this.fields;
tx(() => {
sql(`update orders set state = 'confirmed' where id = ${orderId}`);
new SendReceiptJob({ orderId }).schedule();
});
}
}
Each job in the chain retries on its own. If sending the receipt fails, only SendReceiptJob runs again; the order is not confirmed twice. Where a Trigger.dev task waits for a child, an Elements job writes its result to your tables in its own transaction, and the next job reads it there. Every stage's output is a row you can query, and a chain in progress is a set of rows in Postgres, not a paused process.
A wait is a schedule: schedule("in 3 days") or schedule("next monday"). A waiting job is a row in elements.jobs that no worker picks up until it is due.
A chain can report its progress to the page as it goes. Each job calls notify on a channel, and every browser listening receives the message, with no separate realtime service.
Trigger.dev Bills by the Second; Elements Meters Nothing
Trigger.dev's pricing charges for compute by the second while a task runs, at a rate that rises with machine size, plus a charge for every run. Each plan includes a monthly credit toward that usage. Concurrent runs, schedules and team members are capped by plan, and Pro sells more in blocks. The bill grows with every task your app runs and every second each one takes.
Elements charges up front for the tooling, per machine (pricing, licenses). Jobs, retries, cron ticks and job run time never appear on an Elements bill. A job runs on a server you already rent, at the price your provider publishes.
Cron Is One Line in Your App
Cron is work that runs on a clock: a daily digest at 9am, a cleanup every night. Trigger.dev defines it as a scheduled task. The number of schedules is capped by plan, and the free plan limits how often a schedule can run (pricing).
In Elements, cron is one line in your app's index.ts, written in plain English, with no cap on how many you declare:
app.cron("every day at 9am", "daily digest", () => new SendDailyDigestJob().schedule());
app.cron("every 5m", "rank posts", () => new RankPostsJob().schedule());
When your app runs on several machines, Postgres elects one of them for each tick, so the tick fires once. Every run is recorded in elements.cron_runs in your database, with its duration and any error (jobs).
The Build Checks Your Jobs
On Trigger.dev, a background task means its SDK and task() definitions, a separate deploy to Trigger.dev, and an API call from the app to start each run. For an agent, an SDK is a shortcut only where it does something the agent cannot write quickly from standards. A queue with retries is plumbing every app needs, so Elements ships one, a job class backed by a table in your Postgres. Past that plumbing, Elements adds no layers and puts its engineering into correction: reporting each mistake the moment it is made.
A broken job shows up the same way a broken page does. elements build -json, the build command your agent runs after each edit, reports a job that no longer compiles, a payload field that changed, a failing test or a broken migration in one answer with every other error.
That comes from the project server, a server that runs while you work. It holds the build graph and build state in memory, and on every save the build state is updated almost instantly. It builds in milliseconds and answers agents and humans in microseconds (build).
Developing Trigger.dev tasks locally takes a second process, npx trigger.dev dev, which connects to your Trigger.dev project and links each run to its dashboard. In Elements, the project server starts the job worker itself, and the build finds every class that extends Job with no registration call (jobs).
Running Trigger.dev Yourself Is a Second Platform
Trigger.dev can be self-hosted. Its Docker guide sets up Postgres, Redis, ClickHouse, a container registry, object storage, a realtime stream store and a supervisor. The guide recommends a web app machine with at least 3 vCPUs and 6 GB of RAM and a worker machine with at least 4 vCPUs and 8 GB. Warm starts, autoscaling and checkpoints, which let a waiting task release its machine, are Cloud only.
An Elements app has no second platform to run. The worker and cron run on the app's own servers, the queue is a table in the app's own Postgres, and all of it ships with the same elements deploy.
Questions
What is the difference between Elements and Trigger.dev?
Trigger.dev is a hosted service that runs your background tasks on its own machines, deployed separately from your app and billed by the second. Elements is an integrated app environment with jobs and cron built in: the queue is a table in your own Postgres, a worker runs on your own servers next to the app, and nothing is metered.
Is Elements a Trigger.dev alternative?
Yes. Elements ships durable background jobs, delayed jobs, retries, idempotency keys, priorities and cron as part of the app framework, so an Elements app has no job service to add. The jobs deploy with the app and run on the same servers, and the queue is a table in the same Postgres.
Can Elements run long background jobs?
Yes. A job runs in a worker process on your own server, and each attempt runs for as long as the timeoutMs you set. Longer workflows are chains of jobs: each saves its result and schedules the next in one transaction, and a delay is a schedule such as "in 3 days".
Can I schedule a job inside a database transaction?
Yes. In Elements, schedule() inside tx() inserts the job into the elements.jobs table in the same transaction as your other writes. Workers see the job only after the transaction commits, and a rollback removes it.
How much do background jobs cost in Elements?
Nothing beyond the license. Elements is priced per machine, per month, and jobs, retries, cron ticks and run time are not metered (Elements pricing). Trigger.dev charges for compute by the second and for each run (Trigger.dev pricing).