log in
consulting hosting industries the daily tools about contact

Trigger.dev: Background Jobs That Actually Live in Your Codebase

I've run Sidekiq for years. Trigger.dev is the first JS job runner that made me think I could stop missing it.

I've been running Sidekiq in Ruby projects and Laravel Horizon in PHP projects long enough that I stopped thinking about background jobs as a hard problem. You define a job class, enqueue it, Horizon or Sidekiq runs it, you watch a dashboard. Done. The JS/Node world has never had a clean answer to this. BullMQ is fine but it's infrastructure you wire together yourself. Inngest is interesting but it pushes you toward their cloud in ways I don't love. Trigger.dev is the first thing in the JS ecosystem that gave me the same it just works and it lives in my code feeling I get from Horizon.

Let me tell you what it actually is, where it shines, and where I hit friction.

What Problem It Actually Solves

Background jobs sound simple until they aren't. The real problems are:

  • Durability. If your server dies mid-job, does the work get retried or does it vanish?
  • Observability. When a job fails at 2am, can you see exactly which step failed and what the payload was?
  • Long-running work. Serverless timeouts will kill a 10-minute job. Now what?
  • Code co-location. I hate defining jobs in one place, registering them somewhere else, and configuring retries in a third place.

Trigger.dev attacks all four of these. Jobs are defined as TypeScript functions decorated with their config. The runtime is durable — it checkpoints between steps so a crashed worker doesn't lose progress. And there's a dashboard that shows you a live trace of every task run, every step, every log line, with the actual payload. That last part alone saves me an hour of log-digging per incident.

The model is closer to Temporal or Inngest than to BullMQ. You're not just enqueuing a function call — you're defining a workflow with discrete steps that can each be retried independently.

A Real Example

Here's the kind of thing I'd actually build with this. Imagine a print shop client — they upload a file, I need to: validate it, generate a preview, update the order record, and send a confirmation email. All of this needs to be resilient and auditable. In raw Node this is a mess of try/catches and manual retry logic.

With Trigger.dev v3 (the current version as of this writing), it looks like this:

import { task, logger } from "@trigger.dev/sdk/v3";
import { validatePrintFile } from "../lib/validate";
import { generatePreview } from "../lib/preview";
import { db } from "../lib/db";
import { sendOrderConfirmation } from "../lib/mailer";

export const processUploadedFile = task({
  id: "process-uploaded-file",
  retry: {
    maxAttempts: 3,
    factor: 2,
    minTimeoutInMs: 1000,
    maxTimeoutInMs: 30000,
  },
  run: async (payload: { orderId: string; fileUrl: string }) => {
    const { orderId, fileUrl } = payload;

    logger.info("Starting file processing", { orderId });

    // Step 1: Validate
    const validation = await validatePrintFile(fileUrl);
    if (!validation.ok) {
      logger.error("File validation failed", { orderId, reason: validation.reason });
      // Throwing here marks this run as failed — Trigger retries the whole task
      throw new Error(`Validation failed: ${validation.reason}`);
    }

    logger.info("Validation passed", { orderId, pageCount: validation.pageCount });

    // Step 2: Generate preview (can take 20-30 seconds for large files)
    const previewUrl = await generatePreview(fileUrl);
    logger.info("Preview generated", { orderId, previewUrl });

    // Step 3: Persist
    await db.order.update({
      where: { id: orderId },
      data: {
        status: "ready",
        previewUrl,
        pageCount: validation.pageCount,
      },
    });

    // Step 4: Notify
    await sendOrderConfirmation(orderId);

    return { orderId, previewUrl, pageCount: validation.pageCount };
  },
});

Trigger it from your API route:

import { processUploadedFile } from "../trigger/processUploadedFile";

// In your upload handler
await processUploadedFile.trigger({
  orderId: order.id,
  fileUrl: uploadedUrl,
});

That's it. No queue client setup. No separate worker process config. No Redis connection string buried in a .env that nobody documents. The task definition is the config.

For the truly long-running stuff — like waiting for a third-party rendering service to finish — you'd use wait.for() or triggerAndWait(), which lets Trigger checkpoint the run and resume it without holding a worker thread open. That's the killer feature for serverless environments.

The Gotchas That Bit Me

The deployment model takes adjustment. In local dev you run npx trigger.dev@latest dev, which opens a tunnel to their cloud and routes tasks through it. It works, but it means your local machine is processing jobs that your local API server triggers — which is actually great for development but took me a minute to internalize. In production, you deploy your trigger tasks separately from your main app. They run in Trigger's infrastructure, not yours (on the cloud plan). If you're self-hosting, that's a different story — their self-host story exists but it's not as smooth as, say, spinning up a Horizon worker alongside your Laravel app.

Idempotency is on you. Trigger will retry failed tasks. If your db.order.update already ran before the crash, it'll run again on retry. This isn't unique to Trigger — it's true of every durable job system — but I've seen engineers assume the framework handles it. It doesn't. Design your tasks to be idempotent or use their idempotencyKey option when triggering.

Payload size limits. There's a payload size cap (around 10MB on most plans, but check current docs). I hit this when I naively tried to pass a full parsed document object as payload. The right pattern — which I should have used anyway — is pass an ID or a storage URL, fetch the data inside the task. Good hygiene, but the error message when you exceed the limit isn't the most obvious.

TypeScript is not optional. The SDK is TypeScript-first. You can use it in plain JS but the DX degrades fast. If your project is still vanilla Node/JS, you'll spend time setting up a tsconfig before you get to writing actual tasks. Not a dealbreaker, but factor it in.

Pricing is per task run. The free tier is generous for development and small workloads. But if you're processing tens of thousands of jobs a day, run the numbers before you commit. It's not a surprise bill situation — the pricing is clear — but it's a different cost model than running your own BullMQ + Redis.

When I'd Reach for This

I'd use Trigger.dev when:

  • The project is TypeScript/Node and I need durable, retryable background work without standing up my own queue infrastructure.
  • I need observability baked in — a dashboard showing me exactly what ran, when, and with what payload, without writing custom logging middleware.
  • The jobs might be long-running (minutes, not seconds) and I'm on a serverless or edge hosting setup.
  • I'm building for a client who needs to see the system working — the Trigger dashboard is something I can actually show a non-technical stakeholder.

I wouldn't use Trigger.dev when:

  • The project is PHP/Laravel. Laravel Horizon is a solved problem and it's excellent. I'm not switching stacks for background jobs.
  • I need sub-second job latency at very high throughput. Trigger's model has overhead. For high-frequency, low-latency queuing (like processing websocket events), BullMQ with a local Redis is still faster and more predictable.
  • The client has hard data residency requirements and the self-host story needs to be rock-solid. It's getting there, but I'd verify carefully before committing.
  • I'm on a Node project that's already deeply invested in BullMQ with custom queue logic. Migration cost wouldn't be worth it.

The Self-Hosting Question

I want to address this directly because it comes up with every managed-infrastructure tool. Trigger.dev is open source and you can self-host. I've stood it up on a VM for evaluation and it works. But the self-hosted path requires running their worker infrastructure, and the operational overhead is non-trivial compared to just deploying to their cloud. For most of my clients, the cloud plan is the right call — it's one less thing I'm managing at 2am. For a healthcare client with strict data handling requirements, I'd spend the time to do self-hosted properly and it would be worth it.

Closing

The JS background job space has been a mess for years and Trigger.dev is the most coherent answer I've found. It's not perfect — the self-host story needs polish and the fully-managed model isn't for everyone — but for greenfield TypeScript projects, it's now what I'd default to. Sometimes a tool just fits how your brain works, and this one fits mine.

Need help shipping something like this? Get in touch.