DEV Community

Cover image for Your First Background Job with Trigger.dev — From Hello World to Production
Jordan Sterchele
Jordan Sterchele

Posted on

Your First Background Job with Trigger.dev — From Hello World to Production

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
Enter fullscreen mode Exit fullscreen mode

The init command creates:

trigger.config.ts     # Trigger.dev configuration
src/trigger/          # Your tasks live here
  example.ts          # Example task to get started
Enter fullscreen mode Exit fullscreen mode

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 };
  },
});
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
  },
});
Enter fullscreen mode Exit fullscreen mode

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 };
  },
});
Enter fullscreen mode Exit fullscreen mode

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 };
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 retry configuration — 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 dev before 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)