CodeWorlds
Back to collections
Guide19 min readCodeWorlds Team

Inngest, event-driven background jobs

Inngest runs functions in reaction to events, with steps, retries and flow control. Version 4.18.1, the server SSPL licence and what it actually costs.

Inngest, event-driven background jobs

Inngest is a platform for running background jobs where you do not call a job directly, but send an event instead, and functions declare for themselves which events they react to. The inngest package sits at version 4.18.1, the server in the inngest/inngest repository has roughly 5.7 thousand stars, and its licence is SSPL with a deferred switch to Apache 2.0, which is the thing to check before deploying.

What Inngest does and does not do

The difference between Inngest and an ordinary job queue comes down to the direction of the dependency. In a queue, the code producing work has to know the name of the job to be performed: you push a send-welcome-email entry with a user identifier. In Inngest, the code producing work announces a fact that has just occurred, say app/user.signup, and does not know how many functions will react to it. Adding a second reaction, for instance a write into an analytics system, requires no change at the place where the event originated.

That distinction is practical rather than academic. The event model makes it easier to add a function and harder to trace what actually fires after a single event, because the list of recipients is not visible at the send site. If your project has five jobs and each has exactly one caller, the event model adds an indirection layer for no gain. If you have one business event and seven things that should happen after it, the model starts to pay off.

The server itself is made of several separate services and they are worth knowing, because they show up in logs and in self-hosting configuration. The Event API accepts events over HTTP and authenticates them with an event key. An event stream buffers them ahead of the Runner, which creates new function runs, resumes functions paused by waitForEvent and cancels those matching cancelOn expressions. The queue is multitenant and it is where the flow control mechanisms are implemented. The Executor runs steps and writes intermediate state into the state store.

What Inngest does not do. It is not a database, so results that must outlive the run history have to be persisted by you. It does not replace application error monitoring, which is what Sentry is for. It does not send messages, so for transactional email you still need something like Resend. It does not host your code: functions live in your application and Inngest calls them over HTTP.

Licence, three sources and three different answers

This is the point this text was written for. The GitHub API for the inngest/inngest repository returns NOASSERTION, meaning the detector did not recognise a standard licence. That is not a bug but a correct answer, because the licence file in that repository is none of the licences on the list.

The LICENSE.md file opens with two headings: Server Side Public License version 1.0 dated 16 October 2018, and Apache 2.0 Future License. The bulk of the text is the full SSPL, a licence created by MongoDB and not approved by the Open Source Initiative. Its section thirteen, titled Offering the Program as a Service, states plainly that if you make the functionality of the program available to third parties as a service, you must make the source code of that entire service available to everyone at no charge, downloadable over a network, under the terms of the same licence. The definition of offering as a service is broad in the text and covers enabling third parties to interact with the functionality remotely through a computer network.

At the end of the file sits a Grant of Future License section. In it Inngest irrevocably grants an additional Apache 2.0 licence, effective on the third anniversary of the date a given release of the software was made available. After that date the same version of the code is ordinary permissively licensed software. The construction resembles the Business Source License, though the base text is different.

The divergence between sources is visible here at a glance. The npm registry for the inngest-cli package at version 1.43.0 sets the license field to SEE LICENSE IN LICENSE.md, which declares a non-standard licence and points at the file. GitHub says NOASSERTION. The file says SSPL with deferred Apache 2.0. All three answers agree on the fact that this is not a standard open licence, but no automated tool will tell you what follows from it.

The client package is a separate matter, and there the divergence is real. The inngest package at version 4.18.1 has its registry license field set to Apache-2.0. Unpacking the published archive confirms the same: the package root contains a LICENSE.md with the full text of the Apache License version 2.0. The file packages/inngest/LICENSE.md in the inngest/inngest-js repository holds exactly the same text. The GitHub API for that repository, however, reports GPL-3.0, even though the repository root contains no licence file at all. If your dependency audit tool reads metadata from GitHub rather than from packages, it will record a copyleft licence for a library that is in fact under Apache 2.0.

The practical conclusion is easy to remember. The library you pull into your own code is permissive and poses no problem. The server you might stand up yourself is under SSPL, and reselling it onwards as a service is where the section thirteen clause starts to bite. For internal company use SSPL causes no trouble, because you are not offering the program to third parties. For building a platform product serving external customers on top of it, the trouble is serious.

Client, events and the first function

Installation is a single package. Node 20 or newer is required, along with TypeScript 5.8 or newer if you use the types. The package depends among others on zod at version 3.25, and accepts zod 3.25 or 4 as a peer dependency.

TSsrc/inngest/client.ts
TypeScript
// src/inngest/client.ts
import { Inngest, eventType } from 'inngest'

export const userSignup = eventType('app/user.signup').data<{
  userId: string
  email: string
  accountId: string
}>()

export const inngest = new Inngest({
  id: 'shop-api',
  eventKey: process.env.INNGEST_EVENT_KEY
})

A function is created with createFunction, which takes a configuration object and a handler. In version four of the package the triggers go into the triggers field, as an array, and this is a change from version three where the second argument was a single trigger object.

TSsrc/inngest/functions/onboarding.ts
TypeScript
// src/inngest/functions/onboarding.ts
import { NonRetriableError } from 'inngest'
import { inngest, userSignup } from '../client'

export const onboarding = inngest.createFunction(
  {
    id: 'user-onboarding',
    name: 'Welcome a new user',
    triggers: [{ event: 'app/user.signup' }],
    retries: 6
  },
  async ({ event, step }) => {
    const user = await step.run('fetch-user', async () => {
      const record = await db.users.findById(event.data.userId)
      if (!record) throw new NonRetriableError('No such user in the database')
      return record
    })

    await step.run('send-welcome', () =>
      mailer.send({ to: user.email, template: 'welcome' })
    )

    await step.sleep('wait-three-days', '3d')

    const activated = await step.waitForEvent('wait-for-activation', {
      event: 'app/project.created',
      match: 'data.userId',
      timeout: '7d'
    })

    if (!activated) {
      await step.run('send-nudge', () =>
        mailer.send({ to: user.email, template: 'nudge' })
      )
    }

    return { userId: user.id, activated: Boolean(activated) }
  }
)

Functions have to be served at an HTTP address that Inngest calls into. Adapters are separate entry points of the package and there are more than a dozen, among them inngest/next, inngest/express, inngest/fastify, inngest/hono, inngest/lambda, inngest/cloudflare, inngest/sveltekit and inngest/bun. In a Next.js project it looks like this.

TSapp/api/inngest/route.ts
TypeScript
// app/api/inngest/route.ts
import { serve } from 'inngest/next'
import { inngest } from '@/src/inngest/client'
import { onboarding } from '@/src/inngest/functions/onboarding'

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [onboarding],
  signingKey: process.env.INNGEST_SIGNING_KEY
})

Sending an event from application code is a call to inngest.send. The method accepts a single object or an array and returns the identifiers of accepted events. An event has a name and a data field, optionally also an id used for deduplication and a ts with your own timestamp.

Steps, retries and error handling

A step is the unit of durability. The result of every step.run is persisted on the server side, and when a function is retried the steps already completed do not execute a second time but hand back the stored result. This is why the handler is invoked many times over the course of one run rather than once from start to finish. Code between steps executes on every such invocation, so anything with a side effect has to sit inside a step.

The available step tools are step.run for executing code, step.sleep and step.sleepUntil for waiting, step.waitForEvent for pausing until a matching event arrives, step.invoke for calling another function and receiving its result, step.sendEvent for sending an event durably and step.fetch for network requests. There is also step.ai.infer, which moves a language model call over to the Inngest server side.

Retrying is on by default. The retries field accepts values from zero to twenty and defaults to four. Two exception types change retry behaviour and both are exported from the main package.

Code
TypeScript
import { NonRetriableError, RetryAfterError } from 'inngest'

export const syncInvoice = inngest.createFunction(
  {
    id: 'sync-invoice',
    triggers: [{ event: 'billing/invoice.created' }],
    retries: 8,
    onFailure: async ({ error, event }) => {
      await alerts.page('sync-invoice exhausted its retries', {
        message: error.message,
        eventId: event.data.event.id
      })
    }
  },
  async ({ event, step }) => {
    await step.run('push-to-accounting', async () => {
      const res = await fetch('https://api.accounting.example/invoices', {
        method: 'POST',
        body: JSON.stringify(event.data)
      })

      if (res.status === 422) {
        // the data is permanently wrong, a retry changes nothing
        throw new NonRetriableError(`Rejected: ${await res.text()}`)
      }

      if (res.status === 429) {
        const after = res.headers.get('retry-after') ?? '60'
        throw new RetryAfterError('Provider limit', `${after}s`)
      }

      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json()
    })
  }
)

NonRetriableError ends the run immediately without consuming the remaining attempts. RetryAfterError pushes the next attempt back by the given time, which is the correct reaction to a 429 response carrying a Retry-After header. The onFailure handler fires only once all attempts are exhausted, and that is where a page to a human belongs.

Cancellation works through cancelOn. You give the name of the cancelling event plus a match field in dot notation on which both events must agree, or a full expression in the if field. Separately there is a timeouts field with start and finish subfields: the first bounds the wait in the queue before the run starts, the second the total duration of the run.

Flow control, which is what you come here for

Flow control is what sets Inngest apart from simpler tools in this category. All the options below are fields of the function configuration object and all take an optional key, which is a CEL expression evaluated for every triggering event. The key matters most here, because it turns one global limit into a separate limit for each key value.

Code
TypeScript
export const generateReport = inngest.createFunction(
  {
    id: 'generate-report',
    triggers: [{ event: 'reports/requested' }],

    // at most 5 concurrent runs per customer
    concurrency: [
      { limit: 5, key: 'event.data.customerId', scope: 'fn' },
      { limit: 50, scope: 'env' }
    ],

    // 100 new runs per minute, with 20 spare for a traffic spike
    throttle: { limit: 100, period: '1m', burst: 20 },

    // collapse a burst of edits to the same report into one run
    debounce: { period: '30s', key: 'event.data.reportId', timeout: '10m' },

    // premium customers ahead of the rest
    priority: { run: 'event.data.plan == "pro" ? 300 : 0' },

    // the same event within 24 hours triggers the function once
    idempotency: 'event.data.reportId'
  },
  async ({ event, step }) => {
    /* ... */
  }
)

Three mechanisms get confused, so let us separate them explicitly. concurrency limits how many runs execute at the same time and takes a scope field with values fn, env or account, the latter two requiring a key. throttle limits how many runs start within a given period, the excess waits in the queue, and the burst field adds headroom above the limit for uneven traffic. rateLimit looks similar but behaves differently: excess events are discarded rather than queued, so it is used for suppressing noise, not for smoothing load.

On top of that come two collapsing mechanisms. debounce delays the start by a given period and restarts the countdown on every further matching event, running the function with the last one. The period ranges from one second to seven days, and the timeout field sets a hard bound on the delay. batchEvents gathers events into a batch and hands them to the function as an events array, with maxSize capped at one hundred and timeout between one and sixty seconds.

There is also singleton with a mode field accepting skip or cancel. The first mode skips a new run if one is already in flight for the same key. The second cancels the running one and starts the new one. It is the simplest way to prevent two synchronisation jobs for the same resource from stepping on each other.

Self-hosting and cloud pricing

Self-hosting has been supported since the server 1.0 release, and the current release is 1.43.0 dated 20 August 2026. The documentation states plainly that the Inngest support team does not guarantee direct help for self-hosted instances and points at GitHub issues or a conversation about the enterprise option. That is information to accept up front rather than discover during an outage.

The server starts with one command and by default has no external dependencies. It keeps state in SQLite in the file ./.inngest/main.db and runs an embedded Redis server inside the same process for the queue and run state, with periodic snapshots into SQLite. That arrangement does not scale beyond a single node, so production setups attach external services.

docker-compose.yml
YAML
# docker-compose.yml
services:
  inngest:
    image: inngest/inngest
    command: inngest start
    ports:
      - '8288:8288'
      - '8289:8289'
    environment:
      # both keys must be hex strings with an even number of characters
      - INNGEST_EVENT_KEY=abcd1234
      - INNGEST_SIGNING_KEY=5678ef90
      - INNGEST_POSTGRES_URI=postgres://inngest:password@postgres:5432/inngest
      - INNGEST_REDIS_URI=redis://redis:6379
      - INNGEST_EXPERIMENTAL_PROM_METRICS=1
    depends_on:
      - postgres
      - redis

Port 8288 serves the server and its interface, port 8289 is the Connect gateway for permanently attached workers. Every command line option can be supplied as an environment variable: swap hyphens for underscores, uppercase it and prefix with INNGEST_, so --postgres-uri becomes INNGEST_POSTGRES_URI. The server also exposes a GET /metrics endpoint in Prometheus format, by default with a single inngest_queue_depth gauge, and the function lifecycle counters are enabled by the experimental flag shown above. Authentication for that endpoint is not configured by default, so do not expose it publicly.

The application connects to your own server through INNGEST_DEV=0 and INNGEST_BASE_URL pointing at the instance address, alongside the event and signing keys. Locally you work with npx inngest-cli dev, which brings up a development server with an interface and execution traces.

In the hosted variant the billing unit is an execution, defined as a single function run plus every step inside it. A function with five step.run calls consumes six units per run, and that multiplier is the most important number to work out for your own project. The Hobby plan costs nothing and includes 50 thousand units per month, 5 concurrent steps, 3 users, 500 thousand ingested events, an event size up to 256 KiB, a batch of up to 5 events and 24 hours of trace history. Once the free quota is exhausted execution pauses rather than switching to metered billing. The Pro plan starts at 99 dollars per month with one million units included, metered billing up to twenty million, one hundred concurrent steps with 25 dollars for each additional 25, fifteen users with 10 dollars per extra user, and seven days of trace history. Integration with external observability systems is a 300 dollar line item in the pricing table, and HIPAA compliance an add-on to be agreed. The Enterprise tier carries no published price.

Inngest against the alternatives

CriterionInngesttrigger.devTemporal
Triggering modelevent, many recipientsdirect job invocationworkflow start from a client
Core licenceSSPL with deferred Apache 2.0divergence between repository and packageMIT
Flow controlthrottle, debounce, concurrency, singleton, priorityqueues and concurrency limitspolicies on the task queue side
Where the code runsin your application, called over HTTPon provider infrastructure or your ownin your own worker process
Self-hostingsupported, no support guaranteesupportedsupported, requires a database
Free tier50 thousand units, then a pauseexecution time and task limitsonly Temporal Cloud is paid

Trigger.dev is the closest counterpart and the difference comes down to the triggering model and to where the code physically executes. There you call a task by name and it runs on provider infrastructure, here you send an event and your own server receives an HTTP request. If a job is meant to run for an hour and eat memory, the Inngest model is worse, because it loads your application. If you want job code in the same repository and the same process as the rest of the application, it is better.

Temporal plays in a different league both in guarantees and in deployment cost. It gives full durable execution with an event history and deterministic replay, but requires running worker processes, maintaining a cluster and accepting a programming model in which workflow code must be deterministic. For sending a welcome email after signup that tool is too heavy.

The deployment platform itself deserves separate thought. Server functions on Vercel carry execution time limits, and Inngest works around them by splitting work into steps: each step is a separate HTTP request, so a ten minute process consists of many short calls. That is a sensible workaround, but it also means network overhead grows linearly with the number of steps.

A neighbouring, if narrower, category is covered by Hookdeck: it does not run your functions but receives webhooks from other systems, queues them, retries them and lets you inspect the payload while debugging. It can be a sensible complement, because the problem of dropped inbound events differs from the problem of durable execution. Two things to know before wiring it in: the TypeScript library sits at version 0.4.0 from August 2024 and has no licence field in the registry, there is no Python client at all, and on the pricing page the same billing item appears at three different rates.

Common mistakes

A side effect outside a step. Code between step.run calls executes on every request within a single run. A database write placed there will execute as many times as the function has steps. Anything that changes the state of the world belongs inside a step.

Renaming a step identifier while runs are in flight. The identifier, that is the first argument to step.run, is the key by which the stored result is found. Renaming it during a deploy means in-flight runs will not find their state and will execute the step again.

Confusing throttle with rateLimit. The first queues the excess, the second throws it away. Setting rateLimit where you meant to smooth traffic to an external API ends in quietly dropped events.

A concurrency limit without a key. concurrency: { limit: 5 } means five runs in total, not five per customer. Without a key field one large customer will block the queue for everyone else.

Costing the system by runs rather than by units. A function split into twelve small steps costs thirteen units per run. Over-slicing work into steps is directly billable here.

Assuming that because the project is public the licence is permissive. The server sits on SSPL and building your own customer-facing service on top of it calls for reading section thirteen before the decision is made.

FAQ

Is Inngest open source?

The code is public, but the server licence is not approved by the Open Source Initiative. The LICENSE.md file in the inngest/inngest repository is Server Side Public License 1.0 with an additional, irrevocable Apache 2.0 licence taking effect on the third anniversary of a given release being made available. The inngest client library on npm, by contrast, is Apache 2.0, confirmed both by the registry metadata and by the file inside the published package.

How do throttle, rateLimit and concurrency differ?

throttle limits how many runs start within a period and queues the excess, optionally with headroom in the burst field. rateLimit in the same situation discards the excess events. concurrency does not look at time at all, only at how many runs execute simultaneously, and accepts a scope of fn, env or account.

Can Inngest run without a cloud account?

Yes. The inngest/inngest image with the inngest start command brings up a full server, by default on SQLite with an embedded Redis in the same process. For production you attach an external Postgres with the --postgres-uri flag and an external Redis with --redis-uri. The documentation notes that technical support for such instances is not guaranteed.

How is cost calculated on the paid plan?

The billing unit is an execution, meaning one function run plus every step executed inside it. A function with five steps consumes six units. The Hobby plan gives 50 thousand units per month and pauses execution once they are used up, the Pro plan starts at 99 dollars with one million units included and metered billing above that.

What happens when a function dies halfway through?

Steps already completed have their results stored and will not run a second time on retry. By default a function has four attempts, and the retries field accepts values from zero to twenty. Throwing NonRetriableError ends the run immediately, RetryAfterError pushes the next attempt back by the given time, and once all attempts are exhausted the onFailure handler is invoked.

Can I run a function without sending an event?

Yes, that is what step.invoke is for, calling another function and awaiting its result, along with references created by referenceFunction, which point at a function living in another application. The event model stays the default, but direct invocation is available wherever you need a returned value.

Documentation lives on the Inngest site, pricing on the plans page, and the server code in the GitHub repository.

Read next

We use cookies to enhance your experience on the site