Trigger.dev Deep Dive: Background Jobs, Queue Fan-Out, MCP, and Agent Skills
Trigger.dev is a serverless background job platform that lets you run long-running tasks with no timeouts, automatic retries, queue-based concurrency control, and full observability. Unlike traditional job queues (BullMQ, Celery, Sidekiq), Trigger.dev manages the infrastructure — you write TypeScript tasks and deploy them like functions.
This article covers the platform end-to-end: architecture, task authoring, the queue fan-out pattern, MCP server integration for AI assistants, agent skills/rules, and a production case study of a TTS audio pipeline.
Table of Contents
- Architecture Overview
- Getting Started
- Writing Tasks
- Triggering Tasks
- Queues and Concurrency
- The Fan-Out Pattern with batchTriggerAndWait
- Error Handling and Retries
- Waits and Checkpointing
- Configuration — trigger.config.ts
- MCP Server — AI Assistant Integration
- Agent Skills and Rules
- Claude Agent SDK Integration
- Case Study: TTS Audio Pipeline
- Deployment
- Common Mistakes
Architecture Overview
Trigger.dev implements a serverless architecture without timeouts:
Your App ──trigger──▶ Trigger.dev Platform ──▶ Task Worker (isolated)
◀─handle─── ◀── Task completed
When you run npx trigger.dev@latest deploy, your task code is built and deployed to Trigger.dev's infrastructure. When you trigger a task from your application, it runs in a secure, isolated environment with the resources needed to complete it.
Key architectural properties:
- No timeouts — tasks can run for hours (configurable via
maxDuration) - Durable execution — tasks survive restarts and infrastructure failures
- Automatic checkpointing — when a task waits, execution is suspended and doesn't consume compute
- Queue-based scheduling — every task gets a queue; concurrency is configurable
- Full observability — logs, traces, and run history in the dashboard
Getting Started
Installation
npm install @trigger.dev/sdk
# or
pnpm add @trigger.dev/sdk
Project Configuration
Create a trigger.config.ts at your project root:
import { defineConfig } from "@trigger.dev/sdk/build";
export default defineConfig({
project: "<your-project-ref>", // e.g., "proj_abc123"
dirs: ["./src/trigger"], // where your task files live
maxDuration: 300, // default max duration in seconds
retries: {
enabledInDev: true,
default: {
maxAttempts: 3,
minTimeoutInMs: 1000,
maxTimeoutInMs: 30_000,
factor: 2,
randomize: true,
},
},
});
Development
npx trigger.dev@latest dev # starts the dev server, watches for task changes
Writing Tasks
Tasks are the core primitive. Every task must be a named export using the task() function:
import { task } from "@trigger.dev/sdk";
export const myTask = task({
id: "my-task", // unique identifier
maxDuration: 120, // override global maxDuration (seconds)
retry: {
maxAttempts: 3,
factor: 1.8,
minTimeoutInMs: 500,
maxTimeoutInMs: 30_000,
},
run: async (payload: { url: string }) => {
// Your long-running logic here
return { success: true };
},
});
Schema Validation with Zod
For type-safe payloads with runtime validation, use schemaTask:
import { schemaTask } from "@trigger.dev/sdk";
import { z } from "zod";
export const processVideo = schemaTask({
id: "process-video",
schema: z.object({
videoUrl: z.string().url(),
format: z.enum(["mp4", "webm"]).default("mp4"),
}),
run: async (payload) => {
// payload is typed AND validated at runtime
},
});
Lifecycle Hooks
Tasks support onFailure for cleanup when all retries are exhausted:
export const riskyTask = task({
id: "risky-task",
onFailure: async ({ payload, error }) => {
// Update database, send alerts, clean up resources
await db.markJobFailed(payload.jobId, error.message);
},
run: async (payload) => {
// ...
},
});
Triggering Tasks
From Your Backend (fire-and-forget)
import { tasks } from "@trigger.dev/sdk";
import type { myTask } from "./trigger/my-task";
// Fire and forget — returns a run handle immediately
const handle = await tasks.trigger<typeof myTask>("my-task", {
url: "https://example.com",
});
From Inside Other Tasks
export const parentTask = task({
id: "parent-task",
run: async (payload) => {
// Fire and forget
await childTask.trigger({ data: "value" });
// Wait for result — returns a Result object
const result = await childTask.triggerAndWait({ data: "value" });
if (result.ok) {
console.log(result.output); // the actual return value
}
// Or use .unwrap() to get output directly (throws on failure)
const output = await childTask.triggerAndWait({ data: "value" }).unwrap();
},
});
Batch Triggering
// From backend
const batchHandle = await tasks.batchTrigger<typeof myTask>("my-task", [
{ payload: { url: "https://example.com/1" } },
{ payload: { url: "https://example.com/2" } },
]);
Queues and Concurrency
When you trigger a task, it enters a queue. By default each task gets its own queue, limited only by your environment concurrency. For fine-grained control, define custom queues:
import { task, queue } from "@trigger.dev/sdk";
const apiQueue = queue({
name: "openai-calls",
concurrencyLimit: 5, // max 5 parallel runs in this queue
});
export const callOpenAI = task({
id: "call-openai",
queue: apiQueue,
run: async (payload) => {
// At most 5 instances run concurrently
},
});
Only actively executing runs count toward concurrency. Delayed or waiting runs don't consume slots.
You can also override the queue at trigger time for priority routing:
const handle = await myTask.trigger(data, {
queue: {
name: "high-priority",
concurrencyLimit: 20,
},
});
The Fan-Out Pattern with batchTriggerAndWait
The fan-out pattern is Trigger.dev's answer to parallel processing: an orchestrator task spawns N child tasks, waits for all to complete, then aggregates results.
Orchestrator Task
├── batchTriggerAndWait([...payloads])
│ ├── Child Task 1 ─▶ Result 1
│ ├── Child Task 2 ─▶ Result 2
│ └── Child Task N ─▶ Result N
└── Aggregate results
export const orchestrator = task({
id: "orchestrator",
run: async (payload: { items: string[] }) => {
const results = await childTask.batchTriggerAndWait(
payload.items.map((item) => ({ payload: { item } })),
);
// Inspect individual results
const succeeded = results.runs.filter((r) => r.ok);
const failed = results.runs.filter((r) => !r.ok);
if (failed.length > 0) {
throw new Error(`${failed.length} items failed`);
}
return succeeded.map((r) => r.output);
},
});
Key behaviors:
- The parent task is checkpointed while waiting — no compute charge during the wait
- Child concurrency is controlled by the child task's queue
- Each child run's result is individually inspectable (
ok,output,error)
Important: Never wrap
triggerAndWaitorbatchTriggerAndWaitinPromise.all— parallel waits are not supported. Use the built-in batch functions instead.
Error Handling and Retries
Task-Level Retries
Configured per-task or globally in trigger.config.ts:
export const resilientTask = task({
id: "resilient-task",
retry: { maxAttempts: 5 },
run: async (payload) => {
// Throwing triggers a retry (up to maxAttempts)
},
});
Aborting Retries
For permanent errors that shouldn't be retried:
import { AbortTaskRunError } from "@trigger.dev/sdk";
throw new AbortTaskRunError("Invalid payload, will not retry");
Scoped Retries
Retry a specific block within a task without restarting the whole run:
import { retry } from "@trigger.dev/sdk";
const data = await retry.onThrow(
async () => await fetchExternalApi(payload),
{ maxAttempts: 3 },
);
Waits and Checkpointing
Trigger.dev automatically suspends (checkpoints) tasks during waits, so you don't pay for idle time:
import { wait } from "@trigger.dev/sdk";
// Wait for a duration
await wait.for({ seconds: 30 });
await wait.for({ hours: 1 });
// Wait until a specific date
await wait.until({ date: new Date("2025-06-01") });
// Wait for an external callback (human-in-the-loop)
await wait.forToken({ id: "approval-token", timeout: "24h" });
When a wait exceeds ~5 seconds, the task worker is suspended and resumed when the wait completes. This is fundamental to how Trigger.dev achieves long-running tasks without burning compute.
Configuration — trigger.config.ts
The configuration file at your project root controls build, runtime, and retry behavior:
import { defineConfig } from "@trigger.dev/sdk/build";
export default defineConfig({
project: "proj_gmqcwyqsqcnkjnlqcmxf",
dirs: ["./src/trigger"],
maxDuration: 300,
retries: {
enabledInDev: true,
default: {
maxAttempts: 3,
minTimeoutInMs: 1000,
maxTimeoutInMs: 30_000,
factor: 2,
randomize: true,
},
},
// Build extensions (optional)
build: {
extensions: [],
external: ["some-native-module"], // exclude from bundling
},
// Machine size for deployed tasks
machine: "small-2x",
});
Key options:
dirs— directories containing task files (auto-discovered)maxDuration— global max compute time per task (seconds). Minimum is 5sretries— global retry defaults; individual tasks can overridebuild.external— packages to exclude from the bundle (useful for native deps)machine— compute size (micro,small-1x,small-2x,medium-1x,medium-2x,large-1x,large-2x)
MCP Server — AI Assistant Integration
Trigger.dev provides an MCP (Model Context Protocol) Server that lets AI assistants interact directly with your projects. This is one of the most powerful integrations — it turns your AI coding assistant into a Trigger.dev operator.
