Elements vs Inngest

Markdown

Inngest is a hosted service for background jobs and multi-step workflows, a category it calls durable execution. You write functions with its SDK in TypeScript, Python or Go and trigger them with events or a cron schedule. The functions run in your app, and Inngest queues each run, retries steps that fail and stores the result of each step that succeeds. Its revenue is Inngest Cloud, which bills by usage, counting each function run and each step as an execution. The server is also source-available for teams that run it themselves.

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 where your jobs live. With Inngest, the queue and the state of every step sit in a second company's system, outside your database, and every run and step is counted as a billed execution. In Elements, the queue is a table in your own Postgres, so scheduling a job commits or rolls back with the rest of your writes. A worker on your own servers runs each job, the job history stays in your database, and nothing is metered.

At a Glance

Elements Inngest
What it is An integrated app environment with jobs and cron built in A hosted service that queues and tracks your background functions
What you pay for Tooling, per machine, per month (pricing) Metered executions, one per run and one per step; each plan also caps concurrency, events, workers and seats (pricing)
Where the queue and job state live A table in your app's Postgres Inngest's servers
Job saved in the same transaction as your data Yes: it commits or rolls back with your other writes No: the event goes to Inngest over HTTP, outside your transaction
Retries and failed jobs Retried with exponential backoff, up to a limit you set; failed jobs kept in your Postgres Retries per step; run history on Inngest's servers, limited by plan
Cron One line in your app A function trigger, metered like any other run
Local development Runs with your app, nothing extra to start A separate Dev Server process

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 Inngest, a job starts with an event, and sending an event is a request to Inngest's Event API. That request 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. There is no second system to drift out of sync with your data.

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. Inngest provides that by recording each run and step on its own servers.

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. Each attempt is capped by a timeoutMs you set (jobs).

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;

Multi-Step Work Is a Chain of Jobs

Inngest's steps split a function into pieces, and Inngest stores the result of each finished piece, so a retry skips what already succeeded.

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. Every stage's output is a row in your tables, which you can query while the chain is running.

A wait is a schedule: schedule("in 3 days") or schedule("next monday"). A waiting job is a row in Postgres that uses nothing until it is due. idempotencyKey keeps a job from being queued twice, priority moves urgent work to the front, and Job.cancel(id) withdraws a pending job.

To run more jobs at once, raise the number of worker processes in config.jsoc, or add machines (config).

Every Run and Step Is Metered on Inngest; Nothing Is Metered on Elements

Inngest's pricing counts executions: "A function with 5 step.run() calls uses 6 executions total." Each plan includes a monthly number of executions, and past it, each one is billed. Each plan also sets limits on concurrent steps, events, workers, seats, queue depth and trace data.

The bill grows with everything your app does in the background. Every step you add, for durability or to fit a time limit, adds an execution. On a serverless host, the time limit is real: Vercel Functions stop at five minutes by default and 30 minutes at most on Pro, and Inngest's Vercel guide asks you to set its checkpoint runtime 20 to 40 percent below that.

Elements charges for the tooling, per machine (pricing, licenses). Jobs, chained stages, retries, cron ticks and job history 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. In Inngest, a cron schedule is one more way to trigger a function, and each run counts toward your executions like any other.

In Elements, cron is one line in your app's index.ts, written in plain English:

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 as leader, so each tick fires once. Every run is recorded in elements.cron_runs, with its duration and any error (jobs).

The Build Checks Your Jobs

An agent writing background work is helped most by correction, finding out what it broke the moment it breaks it. Inngest gives it an SDK to learn instead: functions wrapped in its client, steps written through step.run(), a serve() endpoint to register them, and a dev server to run beside the app. Each is an API the agent looks up and a seam between your app and Inngest's queue. An SDK is a shortcut only for code an agent cannot write quickly from standards. Some plumbing every app does need, a durable queue with retries among it, and Elements ships that in the framework as a job class and a table in your Postgres. Past it, Elements adds no layers and puts its engineering into the check.

When you or your agent break a job, you learn it the same way you learn about a broken page. 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 the same answer as every other error, in milliseconds.

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). It also starts the job worker, locally and on every machine you deploy to, so there is nothing extra to run (jobs).

Inngest needs a second process to develop locally, the Inngest Dev Server. Each function is listed in the functions array you pass to serve(), and on deploy the SDK registers them with Inngest using a signing key.

In Elements, a job is a class in app/jobs/ that extends Job, and the build finds it; there is no list to keep and nothing to register. On deploy, every machine gets the worker as its own service, next to the app and the load balancer. Tests run on every machine and migrations run once, on the first machine, before the new release goes live (what deploy does).

Running Inngest Yourself Is Still a Second System

Inngest can be self-hosted. Its self-hosting guide describes a single binary that uses in-memory Redis and SQLite by default, with external Postgres and Redis for production. The guide also says Inngest's support team "does not guarantee direct support for self-hosted instances." The server's license, the Server Side Public License (SSPL), is source-available rather than open source: you can read and run the code, with conditions on offering it as a service, and each release becomes Apache 2.0 only after a delay. Your app still sends its events to that server, and the queue still lives outside your database.

An Elements app has no second system 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 Inngest?

Inngest is a hosted service that queues and tracks your background functions on its own servers and bills per run and per step. 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, and nothing is metered.

Is Elements an Inngest 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 run on the same servers as the rest of the app, and the queue is a table in the same Postgres.

Does Elements have step functions?

Elements has chains of jobs. A job saves its result and schedules the next job in the same transaction, and each job in the chain retries on its own, so a failure reruns only that stage. Delays are schedules 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 and cron ticks are not metered (Elements pricing). Inngest counts every function run and every step as a billed execution (Inngest pricing).