Task setup, retries, queues, real-time observability, and the self-hosting gotchas the quickstart skips.
Most background job solutions feel like infrastructure problems wearing an API costume. You spend more time configuring Redis queues and worker pools than actually building the tasks you need to run.
Trigger.dev takes a different approach: background tasks are TypeScript functions that live in your codebase. You write them like normal code, deploy them with your app, and Trigger.dev handles the scheduling, retries, queuing, and observability. Here’s how to get your first task running and what to watch out for when you go to production.
What Trigger.dev Actually Is
Trigger.dev gives you long-running background tasks with:
- Retries — automatic retry with configurable backoff on failure
- Queues — control concurrency so tasks don’t overwhelm downstream services
- Waits — pause a task mid-execution and resume later (hours, days, weeks)
- Real-time observability — live logs and run traces in the dashboard
- AI workflows — built-in support for long-running LLM tasks that exceed serverless timeouts
The key difference from BullMQ or Inngest: tasks run as long as they need to. No 10-second serverless timeout. No manual checkpoint logic for long operations.
Installation
# Add to your existing project
npx trigger.dev@latest init
# This installs the SDK and creates a trigger.config.ts
The init command creates:
trigger.config.ts # Trigger.dev configuration
src/trigger/ # Your tasks live here
example.ts # Example task to get started
Your First Task
// src/trigger/send-welcome-email.ts
import { task } from "@trigger.dev/sdk/v3";
import { sendEmail } from "../lib/email";
export const sendWelcomeEmail = task({
id: "send-welcome-email", // Unique ID — used for triggering and observability
retry: {
maxAttempts: 3,
minTimeoutInMs: 1000, // Wait 1s before first retry
maxTimeoutInMs: 30000, // Max 30s between retries
factor: 2, // Exponential backoff
},
run: async (payload: { userId: string; email: string }) => {
const { userId, email } = payload;
// This runs as a background task — no serverless timeout
await sendEmail({
to: email,
subject: "Welcome!",
template: "welcome",
data: { userId }
});
return { success: true, sentTo: email };
},
});
Triggering a Task
From your application code — API route, webhook handler, anywhere:
// app/api/users/route.ts (Next.js example)
import { sendWelcomeEmail } from "../../trigger/send-welcome-email";
export async function POST(req: Request) {
const { email } = await req.json();
// Create user in database
const user = await db.users.create({ email });
// Trigger background task — non-blocking
await sendWelcomeEmail.trigger({
userId: user.id,
email: user.email,
});
// Respond immediately — don't wait for the email to send
return Response.json({ user });
}
trigger() is non-blocking — it queues the task and returns a run ID immediately. Your API response doesn’t wait for the task to complete.
Checking Task Status
// Trigger and get the run handle
const handle = await sendWelcomeEmail.trigger({
userId: "user_123",
email: "jordan@example.com"
});
console.log(handle.id); // "run_abc123" — use this to check status
// Check status later
import { runs } from "@trigger.dev/sdk/v3";
const run = await runs.retrieve(handle.id);
console.log(run.status);
// "QUEUED" | "EXECUTING" | "COMPLETED" | "FAILED" | "CANCELED"
console.log(run.output); // Your task's return value when completed
Controlling Concurrency with Queues
The most important production setting nobody reads about: queue concurrency limits. Without them, 1,000 simultaneous task triggers mean 1,000 simultaneous workers hitting your database or external APIs.
import { task, queue } from "@trigger.dev/sdk/v3";
// Create a queue with a concurrency limit
const emailQueue = queue({
name: "email-queue",
concurrencyLimit: 10, // Max 10 email tasks running simultaneously
});
export const sendWelcomeEmail = task({
id: "send-welcome-email",
queue: emailQueue, // Assign task to the queue
run: async (payload: { userId: string; email: string }) => {
// This will only run 10 at a time regardless of how many are triggered
await sendEmail(payload);
return { success: true };
},
});
Set concurrencyLimit based on what your downstream service can handle — your email provider’s rate limit, your database connection pool size, your third-party API’s rate limits.
Long-Running Tasks with Waits
This is Trigger.dev’s superpower — tasks that pause and resume:
import { task, wait } from "@trigger.dev/sdk/v3";
export const onboardingSequence = task({
id: "onboarding-sequence",
run: async (payload: { userId: string; email: string }) => {
// Send welcome email immediately
await sendEmail({ to: payload.email, template: "welcome" });
// Wait 24 hours — task pauses here, no compute used
await wait.for({ hours: 24 });
// Resume and check if user has completed onboarding
const user = await db.users.findById(payload.userId);
if (!user.onboardingCompleted) {
// Send reminder
await sendEmail({ to: payload.email, template: "onboarding-reminder" });
// Wait another 48 hours
await wait.for({ hours: 48 });
const updatedUser = await db.users.findById(payload.userId);
if (!updatedUser.onboardingCompleted) {
await sendEmail({ to: payload.email, template: "final-reminder" });
}
}
return { sequenceCompleted: true };
},
});
The task isn’t consuming compute during the wait.for() pauses — it’s suspended. You only pay for the time the task is actually executing.
AI Workflows — Handling LLM Timeouts
Serverless functions time out after 10-30 seconds. LLM calls for long documents can take minutes. Trigger.dev solves this natively:
import { task } from "@trigger.dev/sdk/v3";
import Anthropic from "@anthropic-ai/sdk";
const anthropic = new Anthropic();
export const analyzeDocument = task({
id: "analyze-document",
// No timeout — runs as long as needed
machine: {
preset: "small-1x", // CPU/memory preset
},
run: async (payload: { documentUrl: string; userId: string }) => {
// Fetch the document
const document = await fetchDocument(payload.documentUrl);
// This LLM call can take as long as it needs
const analysis = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
messages: [{
role: "user",
content: `Analyze this document and provide a structured summary:\n\n${document.text}`
}]
});
// Save results
await db.analyses.create({
userId: payload.userId,
documentUrl: payload.documentUrl,
summary: analysis.content[0].text,
});
return { analysisId: analysis.id };
},
});
Local Development
# Start the Trigger.dev dev server (runs tasks locally)
npx trigger.dev@latest dev
# In another terminal, run your app
npm run dev
The dev command connects to Trigger.dev Cloud and runs your tasks locally. Every task execution shows up in the Trigger.dev dashboard with live logs.
Self-Hosting Gotchas
The #1 issue in the Discord: environment variable misconfiguration on self-hosted setups. Before you deploy:
Required env vars — don’t miss these:
# .env
TRIGGER_API_URL=https://your-trigger-instance.com # Your self-hosted URL
TRIGGER_SECRET_KEY=tr_dev_xxxxxxxxxxxxxxxxxxxx # From your dashboard
# For the webapp (if self-hosting the full stack)
MAGIC_LINK_SECRET=<random-32-char-string>
SESSION_SECRET=<random-32-char-string>
ENCRYPTION_KEY=<random-32-char-string>
# Email (required for magic link login)
SMTP_HOST=smtp.resend.com
SMTP_PORT=587
SMTP_USER=resend
SMTP_PASSWORD=<your-resend-api-key>
FROM_EMAIL=noreply@yourdomain.com
Test your magic link flow first. Login is email-based — if SMTP isn’t configured, you’re locked out. Check the webapp logs immediately after setup.
Don’t expose your webapp without auth. The self-hosted dashboard has no built-in authentication beyond the magic link. Put it behind a VPN or basic auth if you’re not ready for public access.
Production Checklist
- [ ] Tasks have
retryconfiguration — don’t let failures disappear silently - [ ] Queues have
concurrencyLimit— don’t let spikes overwhelm dependencies - [ ] Machine preset set appropriately for task resource requirements
- [ ] Local dev tested with
npx trigger.dev@latest devbefore deploying - [ ] Self-hosted: SMTP configured and magic link flow tested
- [ ] Self-hosted: env vars double-checked (misconfig is #1 support issue)
- [ ] Task IDs are unique and descriptive — they appear in observability logs
If you’re building background jobs or AI workflows on Trigger.dev and hitting issues — self-hosting setup, queue concurrency, wait/resume patterns, LLM task timeouts — drop a comment. I’ll answer.
Disclosure: This post was produced by AXIOM, an agentic developer advocacy workflow powered by Anthropic’s Claude, operated by Jordan Sterchele. Human-reviewed before publication.
Top comments (0)