What’s new in v4?

FeatureDescription
Wait for tokenCreate and wait for tokens to be completed, enabling approval workflows and waiting for arbitrary external conditions.
Wait idempotencySkip waits if the same idempotency key is used again when using wait for, wait until, or wait for token.
PrioritySpecify a priority when triggering a task.
Global lifecycle hooksRegister global lifecycle hooks that are executed for all runs, regardless of the task.
onWait and onResumeRun code when a run is paused or resumed because of a wait.
onCompleteRun code when a run completes, regardless of whether it succeeded or failed.
onCancelRun code when a run is cancelled.
Hidden tasksCreate tasks that are not exported from your trigger files but can still be executed.
Middleware & localsThe middleware system runs at the top level, executing before and after all lifecycle hooks. The locals API allows sharing data between middleware and hooks.
useWaitTokenUse the useWaitToken hook to complete a wait token from a React component.
ai.toolCreate an AI tool from an existing schemaTask to use with the Vercel AI SDK.

Node.js support

Trigger.dev runs your tasks on specific Node.js versions:
  • v3: Uses Node.js 21.7.3
  • v4: Uses Node.js 21.7.3

How to migrate to v4

First read the deprecations and breaking changes sections below. We recommend the following steps to migrate to v4:
  1. Install the v4 package.
  2. Run the trigger dev CLI command and test your tasks locally, fixing any breaking changes.
  3. Deploy to the staging environment and test your tasks in staging, fixing any breaking changes. (this step is optional, but highly recommended)
  4. Once you’ve verified that v4 is working as expected, you should deploy your application backend with the updated v4 package.
  5. Once you’ve deployed your application backend, you should deploy your tasks to the production environment.
Note that between steps 4 and 5, runs triggered with the v4 package will continue using v3, and only new runs triggered after step 5 is complete will use v4.
Once v4 is activated in your environment, there will be a period of time where old runs will continue to execute using v3, while new runs will use v4. Because these engines use completely different underlying queues and concurrency models, it’s possible you may have up to double the amount of concurrently executing runs. Once the runs drain from the old run engine, the concurrency will return to normal.

Migrate using AI

Use the prompt in the accordion below to help you migrate your v3 tasks to v4. The prompt gives good results when using Claude 4 Sonnet. You’ll need a relatively large token limit.

Installation

To opt-in to using v4, you will need to update your dependencies to the latest version:
npx trigger.dev@latest update
This command should update all of your @trigger.dev/* packages to a 4.x version.

Deprecations

We’ve deprecated the following APIs:

@trigger.dev/sdk/v3

We’ve deprecated the @trigger.dev/sdk/v3 import path and moved to a new path:
// This still works, but will be removed in a future version
import { task } from "@trigger.dev/sdk/v3";

// This is the new path
import { task } from "@trigger.dev/sdk";

handleError and init

We’ve renamed the handleError hook to catchError to better reflect that it can catch and react to errors. handleError will be removed in a future version. init was previously used to initialize data used in the run function:
import { task } from "@trigger.dev/sdk";

const myTask = task({
  init: async () => {
    return {
      myClient: new MyClient(),
    };
  },
  run: async (payload: any, { ctx, init }) => {
    const client = init.myClient;
    await client.doSomething();
  },
});
This has now been deprecated in favor of the locals API and middleware. See the Improved middleware and locals section for more details.

toolTask

We’ve deprecated the toolTask function, which created both a Trigger.dev task and a tool compatible with the Vercel AI SDK:
import { toolTask, schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
import { generateText } from "ai";

const myToolTask = toolTask({
  id: "my-tool-task",
  run: async (payload: any, { ctx }) => {},
});

export const myAiTask = schemaTask({
  id: "my-ai-task",
  schema: z.object({
    text: z.string(),
  }),
  run: async (payload, { ctx }) => {
    const { text } = await generateText({
      prompt: payload.text,
      model: openai("gpt-4o"),
      tools: {
        myToolTask,
      },
    });
  },
});
We’ve replaced the toolTask function with the ai.tool function, which creates an AI tool from an existing schemaTask. See the ai.tool page for more details.

Breaking changes

Queue changes

Previously, it was possible to specify a queue name of a queue that did not exist, along with a concurrency limit. The queue would then be created “on-demand” with the specified concurrency limit. If the queue did exist, the concurrency limit of the queue would be updated to the specified value:
await myTask.trigger({ foo: "bar" }, { queue: { name: "my-queue", concurrencyLimit: 10 } });
This is no longer possible, and queues must now be defined ahead of time using the queue function:
import { queue } from "@trigger.dev/sdk";

const myQueue = queue({
  name: "my-queue",
  concurrencyLimit: 10,
});
Now when you trigger a task, you can only specify the queue by name:
await myTask.trigger({ foo: "bar" }, { queue: "my-queue" });
Or you can set the queue on the task:
import { queue, task } from "@trigger.dev/sdk";

const myQueue = queue({
  name: "my-queue",
  concurrencyLimit: 10,
});

export const myTask = task({
  id: "my-task",
  queue: myQueue,
  run: async (payload: any, { ctx }) => {},
});

// You can optionally specify the queue directly on the task
export const myTask2 = task({
  id: "my-task-2",
  queue: {
    name: "my-queue-2",
    concurrencyLimit: 50,
  },
  run: async (payload: any, { ctx }) => {},
});
Now you can trigger these tasks without having to specify the queue name in the trigger options:
await myTask.trigger({ foo: "bar" }); // Will use the queue defined on the task
await myTask2.trigger({ foo: "bar" }); // Will use the queue defined on the task

Releasing concurrency on waits

We’ve changed the default behavior on how concurrency is released when a run is paused or resumed because of a wait. Previously, the concurrency would be released immediately when the run was first paused, no matter the settings on the queue. Now we will no longer release concurrency on a queue that has a specified concurrencyLimit when a run is paused. You can go back to the previous behavior by setting the releaseConcurrencyOnWaitpoint option to true on the queue:
const myQueue = queue({
  name: "my-queue",
  concurrencyLimit: 10,
  releaseConcurrencyOnWaitpoint: true,
});
You can also now control whether concurrency is released when performing a wait:
// This will prevent the run from being released back into the queue when the wait starts
await wait.for({ seconds: 10, releaseConcurrency: false });
The new default behavior allows you to ensure that you can control the number of executing & waiting runs on a queue, and guarantee runs will resume once they are meant to be resumed.
If you do choose to release concurrency on waits, be aware that it’s possible a resume is delayed if the concurrency that was released is not available at the time the wait completes. In this case, the run will go back into the queue and will resume once concurrency becomes available.
This new behavior effects all the wait functions:
  • Wait for duration (e.g. wait.for({ seconds: 10 }))
  • Wait for a child task to complete (e.g. myTask.triggerAndWait(), myTask.batchTriggerAndWait([...]))
  • Wait for a token to complete (e.g. wait.forToken(tokenId))

Lifecycle hooks

We’ve changed the function signatures of the lifecycle hooks to be more consistent and easier to use, by unifying all the parameters into a single object that can be destructured. Previously, hooks received a payload as the first argument and then an additional object as the second argument:
import { task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  onStart: (payload, { ctx }) => {},
  run: async (payload, { ctx }) => {},
});
Now, all the parameters are passed in a single object:
import { task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  onStart: ({ payload, ctx }) => {},
  // The run function still uses separate parameters
  run: async (payload, { ctx }) => {},
});
This is true for all the lifecycle hooks:
import { task } from "@trigger.dev/sdk";

export const myTask = task({
  id: "my-task",
  onStart: ({ payload, ctx, task }) => {},
  onSuccess: ({ payload, ctx, task, output }) => {},
  onFailure: ({ payload, ctx, task, error }) => {},
  onWait: ({ payload, ctx, task, wait }) => {},
  onResume: ({ payload, ctx, task, wait }) => {},
  onComplete: ({ payload, ctx, task, result }) => {},
  catchError: ({ payload, ctx, task, error, retry, retryAt, retryDelayInMs }) => {},
  run: async (payload, { ctx }) => {},
});

Context changes

We’ve made a few small changes to the ctx object:
  • ctx.attempt.id and ctx.attempt.status have been removed. ctx.attempt.number is still available.
  • ctx.task.exportName has been removed (since we no longer require tasks to be exported to be triggered).

BatchTrigger changes

The batchTrigger function no longer returns a runs list directly. In v3, you could access the runs directly from the batch handle:
// In v3
const batchHandle = await tasks.batchTrigger([
  [myTask, { foo: "bar" }],
  [myOtherTask, { baz: "qux" }],
]);

// You could access runs directly
console.log(batchHandle.runs);
In v4, you now need to use the runs.list() method to get the list of runs:
// In v4
const batchHandle = await tasks.batchTrigger([
  [myTask, { foo: "bar" }],
  [myOtherTask, { baz: "qux" }],
]);

// Now you need to call runs.list()
const runs = await batchHandle.runs.list();
console.log(runs);