CodeWorlds
Back to collections
Guide20 min readCodeWorlds Team

Trigger.dev, background jobs for TypeScript

Trigger.dev runs long background jobs with no function timeout. Version 4.5.12, retries, schedules, cloud pricing, and what the self-hosted build leaves out.

Trigger.dev, background jobs for TypeScript

Trigger.dev is a service for running background jobs written in TypeScript: functions that take minutes or hours, retry after a failure, and come with a step by step view of every run. The current version is 4.5.12 from 20 August 2026, the triggerdotdev/trigger.dev repository has around 16.1 thousand stars, and it is not archived.

What Trigger.dev actually does

You write an ordinary function in a file inside a trigger directory, wrap it in task(), and from then on call it from your application through trigger() instead of await. The call returns immediately with a run identifier, and the work itself happens on a machine on the Trigger.dev side.

That settles three things at once which a hand rolled queue handles separately. The first is durability: the job lands in a queue, survives a deployment of a new application version, and survives a process restart. The second is retrying: when the function throws, the attempt is repeated with a growing delay according to rules you set declaratively. The third is observability: every run gets its own trace in the dashboard with logs, per step duration, and the full input payload, so after a failed job you can see exactly what arrived at it.

The scope is limited to the Node ecosystem. The @trigger.dev/sdk package declares engines.node at 18.20.0 or higher and there are no client libraries for Python or Go. If your application's background work is polyglot, that constraint will decide the matter faster than any other feature.

What Trigger.dev does not do matters too. It is not a message broker and will not replace a queue between services. It is not a database, so state still lives in Prisma or in Supabase. It does not send mail, that is what Resend is for, although sending from a background job is one of the most common use cases.

Why an API route and vendor cron are not enough

This question always comes up and deserves a concrete answer, because both answers are sometimes right.

The first problem is the execution time limit. A serverless function has an upper bound on how long it runs and gets terminated by the platform once it passes. In Vercel you set this by exporting maxDuration from a route file in the app router or through the functions field in vercel.json, and a function running longer than the declared limit is terminated. When importing a thousand rows from a file, generating a report, or chaining language model calls, that is not a theoretical constraint but the thing the first deployment breaks against. Trigger.dev inverts the arrangement: maxDuration is your decision here, the minimum value is 5 seconds, and timeout.None removes the cap entirely. More importantly, maxDuration is compared against CPU time rather than wall clock time, so waiting inside wait.for, triggerAndWait, and batchTriggerAndWait does not count towards it at all.

The second problem is visibility. A hosting vendor's cron hits your HTTP route and knows exactly as much about it as the status code says. When a job fails midway through a loop over two hundred records, the logs hold one 500 and an exception message without context. You do not know which record, with what payload, how many times it tried before, or whether the previous run finished at all. Debugging a background job without a view of the run is guessing rather than diagnosis, and that cost, more often than the time limit, is what convinces a team to adopt a separate service.

The third is retrying and concurrency. Repeating a failed HTTP call needs your own attempts table, and limiting how many parallel jobs hammer a third party API needs your own semaphore. Both can be written. The question is whether you want to maintain them.

When an API route and cron are enough: the job finishes in a few seconds, runs rarely, and a failed run needs no post mortem. Sending one email after registration or a nightly table cleanup fit that description and need no extra service.

Version, license, and project status

Version 4.5.12 shipped on 20 August 2026, the previous 4.5.11 a week earlier. The last change on the main branch dates from 21 August 2026, the repository has 1414 forks and 423 open issues. Releases arrive every few days, and separate helm-vX.Y.Z tags cover the charts for Kubernetes deployments.

The license carries a discrepancy worth knowing during a dependency audit. The LICENSE file in the repository root holds the full text of the Apache License 2.0 and the GitHub API reports Apache-2.0 for this repository. The license field in the npm registry for the trigger.dev and @trigger.dev/sdk packages at version 4.5.12, however, reads MIT, and decisively, the licence file inside the published tarball also carries the MIT text. In other words what you actually install is MIT, even though the repository declares Apache. Both licenses are permissive and neither introduces a source available clause or a commercial restriction, so there is no practical risk here. The consequence is different: a tool collecting metadata from GitHub and a tool reading package.json will give you two different answers to the same question. If you keep a license register at your company, record both and note where each came from.

There is, though, a hard version cutoff that can hurt during an upgrade. Version 4.5.0 is the last one officially supported for jobs written against SDK version 3. From 4.5.1 upwards the server rejects v3 triggers, batch triggers, and deploys, returning a migration message. If you have old code in production, upgrading an instance is not a cosmetic change but a forced migration.

Installation and your first task

Getting into an existing project comes down to four commands.

Code
Bash
# project setup, creates trigger.config.ts and the trigger directory
npx trigger.dev@latest init

# local working mode, attaches to the DEV environment
npx trigger.dev@latest dev

# deploy tasks to the production environment
npx trigger.dev@latest deploy

# list of available commands with descriptions
npx trigger.dev@latest --help

The init command creates a config file in the project root. It looks like this.

Code
TypeScript
import { defineConfig } from '@trigger.dev/sdk'

export default defineConfig({
  project: 'proj_gtcwttqhhtlasxgfuhxs',
  dirs: ['./trigger'],
  maxDuration: 300,
  machine: 'small-2x',
  retries: {
    enabledInDev: false,
    default: {
      maxAttempts: 3,
      minTimeoutInMs: 1000,
      maxTimeoutInMs: 10000,
      factor: 2,
      randomize: true
    }
  }
})

The dirs field points at the directories holding your tasks. Without it Trigger.dev looks for directories named trigger on its own, but the documentation recommends listing paths explicitly. Files with .test or .spec in the name are skipped automatically, and custom exclusion patterns go into ignorePatterns. Setting retries.enabledInDev to false is the value init assumes and it makes sense: during local debugging three repetitions of the same exception only obscure the picture.

The task itself is a file in the trigger directory.

Code
TypeScript
import { task, logger } from '@trigger.dev/sdk'

export const generateReport = task({
  id: 'generate-report',
  machine: 'medium-1x',
  maxDuration: 1800,
  retry: {
    maxAttempts: 5,
    factor: 1.8,
    minTimeoutInMs: 500,
    maxTimeoutInMs: 30_000,
    randomize: true
  },
  run: async (payload: { organizationId: string; month: string }) => {
    logger.info('report started', { organizationId: payload.organizationId })

    const rows = await loadInvoices(payload.organizationId, payload.month)
    const url = await renderPdf(rows)

    return { url, rowCount: rows.length }
  }
})

The id field must be unique within the project and it is the identifier the server uses, not the variable name. Changing it after deployment cuts you off from the run history, so treat it like a database key. The value returned from run shows up in the dashboard and can be read by the caller, but it is not a channel for large payloads, because the output size is capped.

Calling it from the application, for instance from an API route in Next.js, looks like this.

Code
TypeScript
import { tasks } from '@trigger.dev/sdk'
import type { generateReport } from '@/trigger/generate-report'

const handle = await tasks.trigger<typeof generateReport>(
  'generate-report',
  { organizationId, month: '2026-08' },
  { idempotencyKey: `report-${organizationId}-2026-08`, tags: [organizationId] }
)

return Response.json({ runId: handle.id })

The type only import is deliberate here. It gives the compiler a chance to check the payload while keeping the task code out of the application bundle.

Retries, queues, and waiting

Retry rules are set in two places: defaults in the config file, specifics on the task, with the latter overriding the former. The fields are maxAttempts, factor, minTimeoutInMs, maxTimeoutInMs, and randomize. The last one adds random jitter to the delays, which matters when a thousand jobs fail at once because a third party API went down and all of them would otherwise come back in the same second.

Retrying operates on the whole task, so a step completed before the error runs a second time. That is the most important consequence to hold in mind when designing: either the steps are idempotent, or you split the work into smaller tasks joined through triggerAndWait, where each has its own retry rules and its own history in the dashboard.

Concurrency is steered through queues. By default every task gets its own queue with no cap, and the only limit is the environment ceiling. An environment has a base limit and a burstable limit, by default twice as high, while a single queue never exceeds the base limit. Only actively executing runs count towards a queue; delayed runs and runs waiting in line take no slots.

Code
TypeScript
import { task, queue, wait } from '@trigger.dev/sdk'

export const externalApiQueue = queue({
  name: 'external-api',
  concurrencyLimit: 3
})

export const syncContact = task({
  id: 'sync-contact',
  queue: externalApiQueue,
  run: async (payload: { contactId: string }) => {
    await pushToCrm(payload.contactId)

    await wait.for({ minutes: 15 })

    const status = await readCrmStatus(payload.contactId)
    return { status }
  }
})

export const cleanupTask = task({
  id: 'cleanup',
  queue: { concurrencyLimit: 1 },
  run: async () => {
    await vacuumOldRows()
  }
})

A named queue lets several tasks share one limit, which is the right tool when three different tasks hit the same external API capped at three connections. The shorthand queue: { concurrencyLimit: 1 } placed directly on a task constrains that one task only.

Waiting comes in four forms: wait.for() for a period, wait.until() for a specific date, wait.forToken() until a token is completed by an external event, and inputStream.wait() for data on an input stream. In Trigger.dev Cloud a wait longer than 5 seconds stops the machine and that time is not billed as compute usage. Here sits a detail easy to miss: free compute is not the same as freed concurrency. The concurrency slot only returns once the machine has been snapshotted, which for wait.for and wait.until happens 60 seconds into the wait. A shorter wait holds the slot for its entire duration.

Schedules and the run view

A recurring task is declared through schedules.task(), but declaring it starts nothing. The schedule has to be attached separately, in the dashboard or from code.

Code
TypeScript
import { schedules, logger } from '@trigger.dev/sdk'

export const dailyDigest = schedules.task({
  id: 'daily-digest',
  run: async (payload) => {
    logger.info('schedule fired', {
      scheduleId: payload.scheduleId,
      externalId: payload.externalId,
      timezone: payload.timezone,
      lastTimestamp: payload.lastTimestamp,
      upcoming: payload.upcoming
    })

    const since = payload.lastTimestamp ?? new Date(Date.now() - 86_400_000)
    await sendDigestSince(since)
  }
})

await schedules.create({
  task: dailyDigest.id,
  cron: '0 7 * * *',
  timezone: 'Europe/Warsaw',
  externalId: organizationId,
  deduplicationKey: `digest-${organizationId}`
})

The payload of a scheduled task carries six fields: timestamp with the planned run moment, lastTimestamp with the previous run or undefined on the first one, timezone in IANA format defaulting to UTC, scheduleId, an optional externalId, and upcoming with the next five occurrences. In practice lastTimestamp is the most useful of these, because it lets you process exactly the period that elapsed since the last successful run instead of assuming the job fired on time.

For schedules created from code, deduplicationKey is mandatory in practice even though the type says otherwise. Without it the same schedule is added again on every call, the task starts firing multiple times, the bill grows, and you hit the schedule limit on your plan.

The run view is the part you cannot reproduce with terminal logs. The dashboard shows the task tree with per step timing, the input payload, the output, and every attempt, and a failed run can be replayed with one click on the same payload. On top of that come alerts and a realtime mechanism that exposes run state to the browser through the @trigger.dev/react-hooks package.

Code
TypeScript
'use client'

import { useRealtimeRun } from '@trigger.dev/react-hooks'

export function ReportProgress({
  runId,
  publicAccessToken
}: {
  runId: string
  publicAccessToken: string
}) {
  const { run, error } = useRealtimeRun(runId, { accessToken: publicAccessToken })

  if (error) return <p>Could not read the run state.</p>

  return (
    <div>
      <p>Status: {run?.status}</p>
      {run?.output ? <a href={run.output.url}>Download the report</a> : null}
    </div>
  )
}

The hook requires a public access token, generated on the server and passed into the component. With a self-hosted instance there is also a baseURL option pointing at your own address.

Cloud pricing and plan limits

Billing has two components: a fixed plan fee plus compute usage above the included allowance. Usage depends on the chosen machine and actual CPU time, and runs in the DEV environment are not billed at all.

PlanFeeIncluded usageProduction concurrencySchedulesLog retention
Free0 USD5 USD20101 day
Hobby10 USD per month10 USD501007 days
Pro50 USD per month50 USD200, expandable1000, expandable30 days
Enterprisecustom quote50 USD200, expandable1000, expandable30 days

Three items in that table need a comment. The free plan requires a verified GitHub account and once the 5 USD of credit runs out, tasks stop running until you move to a paid plan. Log retention of one day on the free plan largely voids the observability argument, because a job that failed over the weekend leaves no trace by Monday. The free plan also has no staging environment and no preview branches, which matters for team work.

Technical limits shared across the cloud add to that. The API accepts 1500 requests per minute, and every SDK call counts as one request, so calling trigger() in a loop over a thousand records is the most common way to bounce off that ceiling. The right answer is batchTrigger(), which holds up to 1000 tasks in a single call on SDK 4.3.1 and later, and 500 on earlier versions. A queue holds from 10 thousand runs on the free plan up to a million on Pro, counted per queue rather than across the environment. All cloud runs carry an enforced maximum time to live of 14 days, after which queued entries disappear.

One inconsistency in the vendor's own material deserves to be said plainly: the pricing page lists different concurrency values than the documentation page on limits, which quotes 10 runs for the free plan, 25 for Hobby, and over 100 for Pro. Before sizing throughput, check the Limits page inside your own dashboard, since it shows the values in force for your organisation.

MachinevCPUMemoryDisk
micro0.250.25 GB10 GB
small-1x (default)0.50.5 GB10 GB
medium-1x12 GB10 GB
large-2x816 GB10 GB

The machine drives cost directly, so raising the preset without a reason is the simplest route to an unnecessarily high bill. On the other hand too little memory ends in a TASK_PROCESS_OOM_KILLED error, which Trigger.dev also detects when a child process such as ffmpeg blows past the limit.

Self-hosting and what it leaves out

The source lets you run the whole thing on your own infrastructure through Docker Compose or Kubernetes. The architecture splits into two independently scaled parts: the web application together with the dashboard, Redis, and Postgres, and the execution layer with the supervisor and the runners that execute your tasks.

The documentation says the self-hosted version is functionally the same as the cloud one, and immediately lists the exceptions. Warm starts are missing, meaning slower startups for consecutive runs. Auto-scaling is missing, so you size the number of worker nodes by hand. Dedicated support is missing, leaving the Discord channel. The most serious gap, though, is the absence of checkpoints, because they are what makes waits non-blocking. Without them a task waiting a day inside wait.for holds resources for that entire day instead of being snapshotted and resumed. If your working pattern rests on long waits, that single line decides the matter.

Limits on a self-hosted instance are mostly configurable through environment variables on the web application container: concurrency, rate limits, queue sizes, payload and output sizes, batch size, log size, machine definitions, and OpenTelemetry settings. What stays fixed: the input and output packet length set at 128 KB, along with the caps on alerts, schedules, team members, and preview branches, set to values you will not reach in practice. Logs are never deleted, which is an advantage until you start hunting for disk space. Machine presets are overridden with a JSON file pointed at by MACHINE_PRESETS_OVERRIDE_PATH, and the maximum run time to live by the RUN_ENGINE_DEFAULT_MAX_TTL variable.

The vendor recommends sticking to version tagged releases and keeping the server version aligned with the command line tool version. That is sound advice, and with the cutoff between 4.5.0 and 4.5.1 it is outright necessary.

Trigger.dev against the alternatives

ToolWhat you getMain constraintPick it when
Trigger.devTypeScript tasks, retries, schedules, a dashboard with runsNode only, no checkpoints when self-hostedA TypeScript application with long background jobs
TemporalDurable workflows with guaranteed state replay, many languagesHigher entry barrier, a separate cluster to maintainCritical processes, several languages, long sagas
BullMQ with RedisA queue library inside your own process, full controlDashboard, retries, and scaling are yours to buildYou already run Redis and need a simple in service queue
Hosting vendor cronAn HTTP route called at a set time, zero configurationFunction time limit, no view of the runShort recurring jobs with no need for diagnosis

Choosing between Trigger.dev and Temporal comes down to how much you want to maintain. Temporal offers stronger guarantees and supports many languages, but the cluster is yours and the entry barrier is real. Trigger.dev is narrower and more convenient, while the cloud variant ties you to a single vendor. Task code stays ordinary TypeScript, so rewriting it for another platform is feasible, but run identifiers, schedules, alerts, and the whole history stay with the service.

Common mistakes

The first is calling trigger() in a loop. Each call is a separate API request and the limit is 1500 per minute. Use batchTrigger() instead.

The second is assuming a task executes exactly once. A retry repeats the whole run, so a step completed before the error runs again. An idempotency key on the trigger protects against a duplicated trigger, but not against a repeated step inside the function.

The third is schedules created from code without a deduplicationKey. Every deployment then adds another copy of the same schedule.

The fourth is confusing wall clock time with CPU time when setting maxDuration. The value refers to CPU time and waits are excluded from it, so a task with a 60 second limit can run for many hours on the calendar.

The fifth is a machine chosen too small by accident. The default small-1x preset has 0.5 GB of memory, which on loading a larger file into memory ends in an out of memory error rather than a readable exception.

The sixth is moving payloads through a task's output field. Output and packet sizes are capped, and when self-hosting the packet has a hard 128 KB threshold. Write large results to object storage and return the address.

The seventh is upgrading a self-hosted instance past version 4.5.0 with unmigrated SDK version 3 tasks. The server will reject triggers and deploys instead of carrying on.

FAQ

Do I need Trigger.dev when I already have vendor cron?

If the job finishes in a few seconds and its failure needs no post mortem, you do not. A separate service starts paying off when the work exceeds the serverless function time limit, when you need retries with growing backoff, or when after a failed run you want to see the input payload and attempt history rather than just an HTTP status code.

Does the self-hosted version have full functionality?

It does not. The code is the same, but warm starts, auto-scaling, dedicated support, and checkpoints are missing. That last item matters, because without checkpoints long waits block resources for their entire duration instead of putting the machine to sleep.

What does running a task cost?

You pay a fixed plan fee plus compute usage above the included allowance, with the rate depending on the machine preset and CPU time. The free plan grants 5 USD of credit and requires a verified GitHub account, Hobby costs 10 USD per month with 10 USD of included usage, Pro 50 USD per month with 50 USD of included usage. Runs in the DEV environment are not billed.

Can a task run for arbitrarily long?

Practically yes, because you set the cap yourself through maxDuration, and timeout.None removes it entirely. The minimum value is 5 seconds. The limit refers to CPU time, so waits inside wait.for, triggerAndWait, and batchTriggerAndWait do not count against it.

Can I use Trigger.dev with a language other than TypeScript?

Not directly. Client packages exist only for Node, and @trigger.dev/sdk requires version 18.20.0 or higher. Tasks can be triggered through the HTTP API from any language, but the task code itself stays in TypeScript or JavaScript. For a polyglot stack, Temporal makes more sense.

What is the license and is anything proprietary?

The LICENSE file in the repository holds the Apache License 2.0, while the packages in the npm registry declare MIT. Both licenses are permissive and contain no clauses restricting commercial use. There is no directory under a separate proprietary license in this repository.

Current limits and versions are described in the Trigger.dev documentation, the plans are listed on the pricing page, and the source lives in the GitHub repository.

Read next

We use cookies to enhance your experience on the site