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(), noMath.random(), no directfetch) - 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 };
},
});
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;
},
});
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:
- Calls
crictl checkpointon the container → tar archive of process state - Wraps it with Buildah into an OCI image:
buildah from scratch → add → config → commit → push - Stores the image reference in PostgreSQL; the blob lives in S3-compatible storage
- 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;
},
});
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.
},
});
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 } },
]);
},
});
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>
);
}
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
# 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
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"] }),
],
},
});
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 };
},
});
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);
},
});
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
},
});
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)