DEV Community

Cover image for Trigger.dev: The Open-Source Durable Execution Platform That Replaces Cron, BullMQ, and Inngest for Long-Running TypeScript Jobs
Syed Anzar
Syed Anzar

Posted on

Trigger.dev: The Open-Source Durable Execution Platform That Replaces Cron, BullMQ, and Inngest for Long-Running TypeScript Jobs

Trigger.dev: The Open-Source Durable Execution Platform That Replaces Cron, BullMQ, and Inngest for Long-Running TypeScript Jobs

You've written the same background job pattern a dozen times. A user uploads a video. Your API returns 202 Accepted. Somewhere, a worker picks it up, transcodes it, uploads the result, sends an email. If the worker crashes halfway through, the user never gets notified. If the video takes 45 minutes, your serverless function times out at 10. So you split it into steps, add a queue, provision Redis, write retry logic, build a dashboard to see what's stuck. Congratulations - you've reinvented a job platform, badly.

This is where most TypeScript teams live. They know BullMQ. They've heard of Temporal. They've tried Inngest. But they keep hitting the same walls: timeouts, no visibility, retry storms that charge customers twice, infrastructure they didn't sign up to operate.

Trigger.dev solves this differently. It's not a queue library. It's not a workflow engine that forces you to rewrite code into deterministic activities. It's a durable execution platform that runs your plain async TypeScript functions on managed compute, checkpoints them mid-execution using Linux CRIU, and resumes them exactly where they left off - hours, days, or weeks later - with zero infrastructure for you to manage.


What Developers Get Wrong About Background Jobs

Myth: "I just need a queue."

A queue gives you push and pop. It doesn't give you:

  • A job that survives a deploy, a crash, or a Kubernetes node rotation
  • Automatic retries that don't redo work already done (idempotency keys + warm starts)
  • A 14-day wait.until() that costs zero compute while paused
  • Realtime progress streaming to your React frontend
  • Observability that shows the full trace of a 3-hour video pipeline

Myth: "Serverless functions are fine if I chain them."

Chaining serverless functions means:

  • Every step fits in the platform timeout (10s–15m)
  • You manually pass state between steps (serialization hell)
  • A failure at step 7 means re-running steps 1–6 unless you built your own checkpointing
  • Cold starts on every step
  • No single trace across the whole workflow

Myth: "Temporal is the only real durable execution."

Temporal is powerful. It also demands:

  • Deterministic workflow code (no Date.now(), no Math.random(), no direct fetch)
  • Splitting every side effect into Activities
  • Running your own workers (even on Temporal Cloud)
  • A week of ramp-up for new engineers
  • Versioning workflows with patching APIs when you change logic

Trigger.dev takes a different bet: your code should run as written. No DSL. No determinism rules. No replay. Just TypeScript.


How Trigger.dev Actually Works: Checkpoint-Resume, Not Event Sourcing

The core insight: a Linux process can be snapshotted and restored. CRIU (Checkpoint/Restore In Userspace) does exactly this - it freezes a process tree, serializes memory pages, CPU registers, open file descriptors, and kernel state to disk, then restores it later on the same or a different machine.

Trigger.dev wraps this in a TypeScript runtime. Here's the lifecycle:

1. Task Runs in an Isolated Container

// tasks/process-video.ts
import { task } from "@trigger.dev/sdk/v3";

export const processVideo = task({
 id: "process-video",
 run: async (payload: { videoUrl: string; userId: string }) => {
 // 1. Download (could take 10 minutes)
 const localPath = await downloadVideo(payload.videoUrl);

 // 2. Transcode with FFmpeg (could take 2 hours)
 const outputPath = await transcode(localPath);

 // 3. Upload result
 const cdnUrl = await uploadToCDN(outputPath);

 // 4. Notify user
 await sendEmail(payload.userId, cdnUrl);

 return { cdnUrl };
 },
});
Enter fullscreen mode Exit fullscreen mode

This runs on Trigger.dev's managed compute (or your self-hosted workers). No timeout. The default machine is small-1x (0.5 vCPU, 0.5 GB RAM); you pick per task: machine: "large-2x" for the transcode step, micro for the email.

2. Wait = Checkpoint + Resource Release

When your code hits a wait point, the runtime checkpoints:

export const longWorkflow = task({
 id: "long-workflow",
 run: async (payload) => {
 const step1 = await doSomething(payload);

 // This pauses for 3 days. Zero compute billed.
 // Full process state (variables, call stack, open FDs) snapshotted via CRIU.
 await wait.for({ days: 3 });

 // Resumes HERE, with `step1` still in scope.
 const step2 = await doSomethingElse(step1);
 return step2;
 },
});
Enter fullscreen mode Exit fullscreen mode

Wait points that trigger checkpoints:

  • wait.for({ minutes: 5 }) / wait.until(date) / wait.forToken(token)
  • triggerAndWait() / batchTriggerAndWait() (waiting on child tasks)
  • wait.forRequest() (human-in-the-loop approval)

Key detail: waits under 60 seconds don't checkpoint - the worker just blocks. This avoids overhead for short pauses.

3. Checkpoint = CRIU Snapshot → OCI Image → Registry

In Kubernetes mode (production), the coordinator:

  1. Calls crictl checkpoint on the container → tar archive of process state
  2. Wraps it with Buildah into an OCI image: buildah from scratch → add → config → commit → push
  3. Stores the image reference in PostgreSQL; the blob lives in S3-compatible storage
  4. Marks the run SUSPENDED, releases the worker, re-enqueues the run

On resume, the run is dequeued, the image is pulled, and the container is restored via CRIU. Execution continues from the instruction after the wait.

4. Warm Starts: Retries That Skip Completed Work

If a task crashes after 45 minutes of a 60-minute run, Trigger.dev can warm-start from the last checkpoint instead of restarting from zero.

export const unreliableTask = task({
 id: "unreliable-task",
 // Only checkpoint for retries if the run lasted > 30s
 retryWarmStartThresholdMs: 30_000,
 run: async (payload) => {
 const a = await step1(); // 10 min
 const b = await step2(a); // 20 min ← crashes here
 const c = await step3(b); // never reached
 return c;
 },
});
Enter fullscreen mode Exit fullscreen mode

On retry, the runtime restores the checkpoint from after step2, reuses the cached result of step1 (via idempotency keys on triggerAndWait), and continues at step3. The 30 minutes of work isn't redone.


The Mental Model: One Concept - Tasks

Everything in Trigger.dev is a task. No workflows, activities, signals, or DSL.

// A task is just an async function with metadata
export const myTask = task({
 id: "my-task", // unique, used for triggering
 queue: { // optional concurrency control
 concurrencyLimit: 10,
 key: "per-user-${payload.userId}" // serialize per user
 },
 machine: "medium-1x", // CPU/RAM preset
 maxDuration: 3600, // optional cap (seconds)
 run: async (payload) => {
 // Your code. Plain TypeScript.
 // fetch, Date.now(), Math.random(), any npm package - all allowed.
 },
});
Enter fullscreen mode Exit fullscreen mode

Triggering from Anywhere

// From your API route (Next.js, Express, Hono, Fastify...)
import { tasks } from "@trigger.dev/sdk/v3";

export async function POST(req: Request) {
 const handle = await tasks.trigger("process-video", {
 videoUrl: "https://...",
 userId: "user_123",
 });
 // handle.id, handle.publicAccessToken for realtime UI
 return Response.json({ runId: handle.id });
}

// From inside another task (fan-out, orchestration)
export const parent = task({
 id: "parent",
 run: async () => {
 // Fire-and-forget
 const handle = await childTask.trigger({ data: 1 });

 // Wait for result (checkpoints parent)
 const result = await childTask.triggerAndWait({ data: 2 });

 // Parallel fan-out with batch
 const handles = await batchTriggerAndWait([
 { payload: { i: 1 } },
 { payload: { i: 2 } },
 { payload: { i: 3 } },
 ]);
 },
});
Enter fullscreen mode Exit fullscreen mode

Realtime: Stream Progress to Your UI

// React frontend
import { useRealtimeRun } from "@trigger.dev/react";

function VideoProgress({ runId, publicAccessToken }) {
 const { run } = useRealtimeRun(runId, publicAccessToken);

 return (
 <div>
 <p>Status: {run?.status}</p>
 <progress value={run?.metadata?.progress ?? 0} max="100" />
 {run?.output && <p>Done: {run.output.cdnUrl}</p>}
 </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

The run metadata updates live as your task progresses. No WebSocket server to build.


Comparison: Where Trigger.dev Wins (and Where It Doesn't)

Dimension Trigger.dev Inngest Temporal BullMQ
Model Durable tasks (checkpoint-resume) Durable steps (HTTP invoke + memoization) Workflow replay (event sourcing) At-least-once queue
Where code runs Trigger.dev compute (Cloud) or your workers (self-host) Your app (HTTP invoke) Your workers Your workers
Languages TypeScript (Python via extension) TS, Python, Go Go, Java, TS, Python, .NET, more Node.js (Python port exists)
Long-running jobs ✅ No timeout, checkpoint on wait ✅ Split into steps ✅ Continue-As-New ✅ If worker stays up
Determinism required ❌ No ❌ No ✅ Yes (workflow code) N/A
Managed compute ✅ Cloud runs your tasks ❌ You run functions ❌ You run workers ❌ You run workers
Self-hosting ✅ Docker/K8s, same codebase Possible, less common ✅ Heavy ops It's a library
Flow control Queues, concurrency keys Best in class (throttle, debounce, priority) Build in workflow code Rate limits, priorities
Learning curve Gentle Gentle Steep Flat
Best for AI pipelines, media jobs, long compute Serverless TS apps, event-driven Payments, sagas, regulated Simple jobs on existing Redis

The fork is where compute runs.

  • Trigger.dev Cloud runs your tasks. You deploy tasks; Trigger.dev owns the queue, the execution environment, and observability.
  • Inngest orchestrates your serverless functions over HTTP. Your functions run on your infra (Vercel, Lambda, your VPS).
  • Temporal manages the server; you run workers that poll it.

If your jobs are minutes to hours of compute (video, AI, PDF, data pipelines), Trigger.dev's managed compute removes the timeout ceiling entirely. If your jobs are short steps triggered by events and you want zero new infrastructure, Inngest is simpler. If you need exactly-once workflow logic across polyglot services and will staff the ops model, Temporal is the standard.


Self-Hosting: No Lock-In, Same Codebase

Trigger.dev is Apache 2.0. The dashboard, orchestrator, workers, and CLI all live in the public monorepo. Self-hosting uses the identical runtime as Cloud.

# Docker Compose (dev/simple prod)
git clone https://github.com/triggerdotdev/trigger.dev
cd trigger.dev
docker compose up -d
Enter fullscreen mode Exit fullscreen mode
# Kubernetes (Helm) - production scale
helm repo add trigger https://triggerdotdev.github.io/helm-charts
helm install trigger trigger/trigger \
 --set postgresql.enabled=true \
 --set redis.enabled=true \
 --set objectStorage.s3.bucket=my-bucket
Enter fullscreen mode Exit fullscreen mode

You operate Postgres, Redis (or Valkey/Dragonfly), and the orchestrator. Cloud-only features you lose: warm starts, auto-scaling, dedicated support, managed checkpoints. Everything else - durable tasks, checkpoints, realtime, build extensions - works identically.

This matters for compliance. GovSignals runs Trigger.dev self-hosted at FedRAMP High. Your security team audits the exact code that runs your workloads. Start on Cloud, migrate to self-hosted (or vice versa) without changing a line of task code.


Build Extensions: FFmpeg, Playwright, Python - No Dockerfile

The hardest part of self-hosted workers: custom dependencies. FFmpeg for video. Playwright for scraping. Python for ML. Trigger.dev solves this at build time:

// trigger.config.ts
import { defineConfig } from "@trigger.dev/sdk/v3";
import { ffmpegExtension } from "@trigger.dev/build/extensions/ffmpeg";
import { playwrightExtension } from "@trigger.dev/build/extensions/playwright";
import { pythonExtension } from "@trigger.dev/build/extensions/python";

export default defineConfig({
 project: "my-proj",
 build: {
 extensions: [
 ffmpegExtension(), // adds ffmpeg, ffprobe
 playwrightExtension(), // adds Chromium + deps
 pythonExtension({ // run Python from TS
 requirements: ["pandas", "numpy", "openai"],
 }),
 // Or install any apt package:
 // aptGetExtension({ packages: ["libreoffice", "imagemagick"] }),
 ],
 },
});
Enter fullscreen mode Exit fullscreen mode

One line in config. The CLI builds a Docker image with those binaries baked in. No Dockerfile to write, no base image to maintain, no RUN apt-get update debugging.


Real-World Patterns

AI Agent with Human-in-the-Loop

export const contentAgent = task({
 id: "content-agent",
 run: async ({ topic }) => {
 const outline = await llm.generate({ prompt: `Outline: ${topic}` });

 // Pause for human approval - days if needed, zero cost
 const approval = await wait.forToken("approval", {
 timeout: "7d",
 });

 if (!approval.approved) {
 return { status: "rejected" };
 }

 const draft = await llm.generate({ prompt: `Write: ${outline}` });
 const edited = await humanReview(draft); // another waitpoint
 return { article: edited };
 },
});
Enter fullscreen mode Exit fullscreen mode

Scheduled Jobs That Survive Deploys

export const dailyReport = task({
 id: "daily-report",
 // Cron schedules are durable - attached to the task, not a separate cron process
 cron: { pattern: "0 6 * * *", timezone: "UTC" },
 run: async () => {
 const data = await fetchAnalytics();
 const pdf = await generatePDF(data);
 await uploadAndEmail(pdf);
 },
});
Enter fullscreen mode Exit fullscreen mode

No cron process to monitor. No "did it run?" guesswork. The dashboard shows every run, its trace, and its output.

Multi-Tenant Queue Isolation

export const tenantJob = task({
 id: "tenant-job",
 queue: {
 // Each tenant gets their own lane; one noisy tenant doesn't starve others
 concurrencyLimit: 5,
 key: "tenant-${payload.tenantId}",
 },
 run: async ({ tenantId, ...payload }) => {
 // Process tenant's work
 },
});
Enter fullscreen mode Exit fullscreen mode

Honest Gaps (as of v3)

  • No sandboxed code execution - coming, not shipped
  • No built-in agent memory across runs - you bring your own DB
  • TypeScript-first - Python via build extension only; no Go, Java, Rust SDKs yet
  • No time-based rate limiting - concurrency caps only
  • No offline local dev - needs connection to dev environment or Cloud
  • No visual workflow builder - code-first by design
  • No LLM cost caps per run - you implement budgeting

When to Adopt Trigger.dev

Start with Trigger.dev if:

  • You write TypeScript/Next.js and have jobs that exceed serverless timeouts
  • You want managed compute so you don't operate Redis + workers
  • You need realtime progress in your UI (AI generation, video processing)
  • You want self-hosting as an exit hatch (compliance, data residency)
  • You're building AI agents with human-in-the-loop, long waits, or tool calling

Stick with BullMQ if:

  • You already run Redis well, jobs are single-step, and you want zero vendor bill
  • You need raw queue primitives (priority, rate limiting, parent-child flows) and are happy operating workers

Pick Inngest if:

  • You deploy to Vercel/Netlify/Cloudflare and want functions to run in your app
  • You need best-in-class flow control (debounce, throttle, priority per key)
  • You want the smoothest local dev experience (HTTP invoke, no new processes)

Pick Temporal if:

  • Workflows touch money, healthcare, or regulated data where exactly-once matters
  • You need polyglot workers (Go, Java, .NET) and will invest in the learning curve
  • You want the most battle-tested durability model (Uber, Stripe, Coinbase scale)

The Bottom Line

Background jobs are infrastructure. Most teams don't want to build infrastructure - they want to ship features. Trigger.dev gives you durable execution as a platform, not a library you bolt onto a queue.

You write tasks. They run on managed compute. They checkpoint when they wait. They resume where they left off. You get observability, realtime, scheduling, and self-hosting for free. The code you write is the code that runs - no translation layer, no determinism tax.

GitHub: https://github.com/triggerdotdev/trigger.dev (16k+ stars, Apache 2.0)
Docs: https://trigger.dev/docs
Try it: npx trigger.dev@latest init - first deploy in ~5 minutes.


Stop managing workers. Start writing tasks that just work.

Top comments (0)