mech.app
Automation

Trigger.dev: Code-First Background Tasks and Event-Driven Orchestration

How Trigger.dev's TypeScript-native workflow engine handles retries, state, and long-running tasks without leaving your codebase.

Source: trigger.dev
Trigger.dev: Code-First Background Tasks and Event-Driven Orchestration

Trigger.dev lets you define event-driven background tasks directly in TypeScript. You write functions that handle retries, queues, and long-running workflows as code, without wiring webhooks through a GUI or learning a new DSL. The platform manages execution, observability, and scaling. You own the logic in your repository.

This changes how you version workflows, handle partial failures, and test orchestration logic. State lives in your codebase instead of a vendor’s database. Retries happen at the step level, not the workflow level. Deployment fits into your existing CI/CD pipeline.

The project launched as a Zapier alternative (v1, February 2023) and evolved into a Temporal alternative for TypeScript (v2). The v1 Show HN drew 745 points and 190 comments. The architectural shift matters because it reveals what developers actually need: durable execution primitives that work with their existing stack, not a new orchestration language. Today, Trigger.dev positions itself as a platform for building AI agents and workflows. This expands the scope beyond simple background tasks to handle complex multi-step agent orchestration.

How It Works

Trigger.dev runs tasks as durable functions. You define a task with an ID, a run function, and optional retry policies. The platform serializes execution state at each step, so if a task crashes or times out, it resumes from the last checkpoint instead of restarting from scratch.

Key components:

  • Task definition: Functions decorated with task() that accept typed inputs and return typed outputs.
  • Event triggers: Subscribe to webhooks, schedules, or internal events. The platform handles deduplication and delivery guarantees.
  • State checkpoints: Automatic serialization after each await. If the process dies, the next execution picks up from the last successful step.
  • Queue controls: Set concurrency limits, priority levels, and rate limits per task or globally.
  • Observability: Built-in tracing for every task run, with logs, spans, and error context.

Deployment happens through a CLI that bundles your tasks and pushes them to Trigger.dev’s managed runtime. You can also self-host the orchestrator if you need to keep execution inside your VPC.

Code-First vs. GUI Orchestration

Traditional tools like Zapier and Make use visual builders. You click to add steps, map fields, and configure retries. Trigger.dev reverses this model: workflows are TypeScript files in your repo, versioned with Git, tested with your existing toolchain, and deployed through CI/CD.

AspectGUI Tools (Zapier)Code-First (Trigger.dev)
VersioningPlatform-managed history, limited Git exportStandard Git workflow, branch per feature
TestingManual clicking or paid test runsUnit tests, integration tests, local dev server
State managementOpaque, limited control over retriesExplicit checkpoints, custom retry logic
DeploymentGUI-first, API-based export availableCLI deploy, fits into existing pipelines
DebuggingPlatform logs, limited stack tracesFull TypeScript stack traces, local breakpoints

The tradeoff: GUI tools enable non-technical users to build workflows through visual interfaces. Code-first tools require engineering time but give you full control over error handling, data transformation, and integration with your existing services.

State Management and Retries

Trigger.dev checkpoints state after every await. If a task calls an external API, waits for a webhook, or processes a file, the platform serializes the result before moving to the next step.

This means:

  • Partial failures are cheap: If step 7 of 10 fails, you retry from step 7, not step 1.
  • Long-running tasks survive restarts: A task that waits for a human approval or a slow batch job can pause for hours or days without holding a connection open.
  • Idempotency is easier: Each step runs exactly once unless you explicitly retry it.

Retry configuration lives in code:

import { task } from "@trigger.dev/sdk/v3";

export const processOrder = task({
  id: "process-order",
  retry: {
    maxAttempts: 5,
    factor: 2,
    minTimeoutInMs: 1000,
    maxTimeoutInMs: 60000,
  },
  run: async ({ orderId }: { orderId: string }) => {
    const order = await db.orders.findUnique({ where: { id: orderId } });
    
    // Checkpoint after database read
    const paymentResult = await stripe.charges.create({
      amount: order.total,
      currency: "usd",
      source: order.paymentToken,
    });
    
    // Checkpoint after payment
    await db.orders.update({
      where: { id: orderId },
      data: { status: "paid", chargeId: paymentResult.id },
    });
    
    // Checkpoint after database write
    await sendEmail({
      to: order.customerEmail,
      subject: "Order confirmed",
      body: `Your order ${orderId} is confirmed.`,
    });
    
    return { success: true, chargeId: paymentResult.id };
  },
});

If the Stripe call fails, the next retry skips the database read. If the email send fails, the payment and database update are already committed. You don’t write explicit checkpoint logic; the platform infers it from await boundaries.

Event Subscription Model

Trigger.dev listens for events through webhooks, schedules, or SDK calls. When an event arrives, the platform queues it, deduplicates based on an idempotency key, and invokes the registered task. This push-based delivery model reduces latency compared to polling-based systems, though many modern automation tools (including Zapier for certain integrations) also support webhook delivery.

Webhook setup (illustrative pattern):

import { task } from "@trigger.dev/sdk/v3";
import { tasks } from "@trigger.dev/sdk/v3";

export const onStripePayment = task({
  id: "on-stripe-payment",
  run: async (event: Stripe.Event) => {
    if (event.type === "charge.succeeded") {
      const charge = event.data.object as Stripe.Charge;
      await fulfillOrder(charge.metadata.orderId);
    }
  },
});

// Register webhook in your API route
app.post("/webhooks/stripe", async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers["stripe-signature"],
    process.env.STRIPE_WEBHOOK_SECRET
  );
  
  await tasks.trigger(onStripePayment.id, event, {
    idempotencyKey: event.id,
  });
  
  res.json({ received: true });
});

The idempotency key ensures that if Stripe retries the webhook, Trigger.dev only processes it once. The task runs asynchronously, so your webhook handler returns immediately.

Concurrency and Queue Control

You can limit how many instances of a task run concurrently, either globally or per key. This prevents rate limit exhaustion, database connection pool saturation, or overwhelming downstream services.

import { task } from "@trigger.dev/sdk/v3";

export const sendBulkEmail = task({
  id: "send-bulk-email",
  queue: {
    concurrencyLimit: 10, // Max 10 emails in flight
  },
  run: async ({ email, body }: { email: string; body: string }) => {
    await emailProvider.send({ to: email, body });
  },
});

// Trigger 1000 emails
for (const user of users) {
  await tasks.trigger(sendBulkEmail.id, {
    email: user.email,
    body: campaignBody,
  });
}

The platform queues all 1000 tasks but only runs 10 at a time. You can also set per-key limits (e.g., max 5 tasks per user ID) to prevent one customer from monopolizing resources.

Observability and Debugging

Every task run gets a trace with:

  • Input and output: Serialized payloads for each invocation.
  • Step-by-step logs: Console output, errors, and timing for each checkpoint.
  • Retry history: How many times a task retried and why.
  • Span metadata: Custom tags, user IDs, or correlation IDs.

The web UI shows a timeline of all steps, so you can see exactly where a task stalled or failed. You can also export traces to OpenTelemetry-compatible backends like Honeycomb or Datadog.

Local development uses a dev server that runs tasks in-process. You can set breakpoints, inspect variables, and step through code without deploying to production.

Deployment Shape

Trigger.dev supports two deployment modes:

  1. Managed cloud: Deploy tasks to Trigger.dev’s infrastructure. The platform handles scaling, retries, and observability. You push code through the CLI, and the platform builds and deploys it.
  2. Self-hosted: Run the orchestrator in your own Kubernetes cluster or VPC. You manage the database (Postgres), queue (Redis or SQS), and worker nodes. Useful for compliance, data residency, or cost control.

Both modes use the same SDK and task definitions. The only difference is where the orchestrator runs.

Common Pitfalls

Scaling and integration challenges to watch for:

  • Serialization limits: Task state is serialized to JSON. Large objects (e.g., several megabytes) can exceed size limits or slow down checkpoints. Store big data in S3 or a database and pass references.
  • Non-deterministic code: If your task generates random IDs or timestamps, retries may produce different results. Use the platform’s ctx.run.id or ctx.run.createdAt for stable values.
  • External service timeouts: If a third-party API hangs, your task hangs. Set explicit timeouts on HTTP clients and handle them gracefully.
  • Queue backlog: If tasks arrive faster than they execute, the queue grows. Monitor queue depth and scale workers or increase concurrency limits.
  • Idempotency key collisions: If two events share the same key, only one runs. Make sure keys are unique per logical event.

Technical Verdict

Use Trigger.dev when you need durable background tasks with full control over retry logic, state management, and deployment. It fits teams that already write TypeScript, use Git for versioning, and want to test workflows locally before deploying.

Avoid it if your team prefers GUI builders, needs non-technical users to create workflows, or already has a working Temporal or Inngest setup. The learning curve is low for developers but non-existent for no-code users.

The platform excels at long-running workflows (hours or days), fan-out tasks (processing thousands of items), and integrations that need custom error handling. It is less suitable for real-time requirements (sub-second latency) or workflows that need complex branching logic better expressed in a visual DAG.

If you’re building an AI agent that calls multiple tools, waits for human feedback, and retries on failure, Trigger.dev gives you the primitives to handle that in code without reinventing orchestration.