Back to Blog

Background Jobs and Task Queues: BullMQ, Inngest, and Trigger.dev Compared

Background Jobs and Task Queues: BullMQ, Inngest, and Trigger.dev Compared

Every non-trivial application eventually needs to do work outside the request-response cycle. Sending emails, generating PDFs, processing uploads, syncing data with third-party APIs — these tasks do not belong in your API handler. They belong in a background job system.

We have used all three of the major TypeScript-native options across our projects: BullMQ, Inngest, and Trigger.dev. Each solves the same core problem differently, and picking the wrong one can cost you weeks of refactoring. Here is what we have learned shipping them in production.

The Core Problem

When a user signs up for MindHyv, we need to send a welcome email, create default workspace settings, provision storage, and notify the team via Slack. None of that should block the signup response. The user should see a success screen in under 200ms while all that work happens asynchronously.

That is the basic case. But real applications pile on complexity: retries on failure, rate limiting against external APIs, scheduled jobs (daily digests, subscription renewals), job prioritization, and observability into what failed and why.

The three tools we are comparing each handle this differently.

Automated background processes running on a server workflow

BullMQ: The Self-Hosted Workhorse

BullMQ is a Redis-backed queue library for Node.js. It is the spiritual successor to Bull, rewritten in TypeScript with better performance and more features. You manage the infrastructure yourself — a Redis instance and one or more worker processes.

Setup

// queue.ts
import { Queue } from "bullmq";

const connection = { host: "localhost", port: 6379 };

export const emailQueue = new Queue("emails", { connection });

// Add a job
await emailQueue.add("welcome-email", {
  userId: "user_123",
  email: "[email protected]",
  template: "welcome",
});
// worker.ts
import { Worker } from "bullmq";

const worker = new Worker(
  "emails",
  async (job) => {
    const { userId, email, template } = job.data;
    await sendEmail({ to: email, template });
    await markEmailSent(userId, template);
  },
  {
    connection: { host: "localhost", port: 6379 },
    concurrency: 5,
    limiter: {
      max: 10,
      duration: 1000, // 10 emails per second
    },
  }
);

worker.on("failed", (job, err) => {
  console.error(`Job ${job?.id} failed:`, err.message);
});

When BullMQ Fits

BullMQ is the right choice when you already run Redis, need fine-grained control over concurrency and rate limiting, or have high-throughput workloads where per-job costs from managed services add up. We have seen it handle tens of thousands of jobs per minute on a single Redis instance.

It also shines for recurring jobs. The repeatableJobs API gives you cron-like scheduling without needing a separate scheduler:

await emailQueue.add(
  "daily-digest",
  { type: "digest" },
  {
    repeat: {
      pattern: "0 9 * * *", // Every day at 9 AM
    },
  }
);

The Tradeoffs

You are responsible for Redis availability, worker process management, and monitoring. If Redis goes down, your jobs go down. You need to build your own dashboard or use Bull Board. Deploying workers means managing long-running processes, which does not play well with serverless platforms like Vercel or Cloudflare Workers.

Inngest: Event-Driven, Zero Infrastructure

Inngest takes a fundamentally different approach. Instead of queues and workers, you define functions that respond to events. The Inngest platform handles queuing, retries, concurrency, and scheduling. Your functions run inside your existing API — no separate worker processes.

Setup

// inngest/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({ id: "my-app" });
// inngest/functions/welcome-email.ts
import { inngest } from "../client";

export const sendWelcomeEmail = inngest.createFunction(
  {
    id: "send-welcome-email",
    retries: 3,
    throttle: {
      limit: 10,
      period: "1s",
    },
  },
  { event: "user/signed-up" },
  async ({ event, step }) => {
    const user = await step.run("fetch-user", async () => {
      return await db.users.findById(event.data.userId);
    });

    await step.run("send-email", async () => {
      await sendEmail({
        to: user.email,
        template: "welcome",
      });
    });

    await step.run("create-defaults", async () => {
      await createDefaultWorkspace(user.id);
    });
  }
);
// Trigger from your API route
await inngest.send({
  name: "user/signed-up",
  data: { userId: "user_123" },
});

The Step Function Model

The killer feature of Inngest is step.run(). Each step is independently retryable. If send-email succeeds but create-defaults fails, Inngest retries only the failed step. With BullMQ, you would need to build that idempotency yourself or split it into separate jobs with manual chaining.

Steps also enable complex workflows without writing state machine code:

export const onboardingFlow = inngest.createFunction(
  { id: "onboarding-flow" },
  { event: "user/signed-up" },
  async ({ event, step }) => {
    await step.run("send-welcome", async () => {
      await sendWelcomeEmail(event.data.userId);
    });

    // Wait 24 hours, then check engagement
    await step.sleep("wait-for-engagement", "24h");

    const hasLoggedIn = await step.run("check-login", async () => {
      return await checkUserActivity(event.data.userId);
    });

    if (!hasLoggedIn) {
      await step.run("send-nudge", async () => {
        await sendNudgeEmail(event.data.userId);
      });
    }
  }
);

When Inngest Fits

Inngest is ideal when you deploy on serverless platforms, want zero infrastructure to manage, and need durable multi-step workflows. The event-driven model also makes it natural for fan-out patterns — one event can trigger multiple functions.

We have used it in projects where the team is small and managing Redis felt like overhead we did not need. The free tier is generous enough for most startups.

Server rack processing queued background jobs

The Tradeoffs

Your functions run on Inngest’s infrastructure, which means cold starts and the inherent latency of HTTP-based invocation. You are also dependent on Inngest’s availability. For extremely high-throughput workloads (millions of jobs per hour), the per-execution pricing can exceed the cost of self-hosting BullMQ.

Trigger.dev: Serverless Jobs with Full Control

Trigger.dev sits between BullMQ and Inngest. It is a managed platform like Inngest, but your jobs run in isolated serverless environments with longer execution times and more control over the runtime.

Setup

// trigger/welcome-email.ts
import { task } from "@trigger.dev/sdk/v3";

export const sendWelcomeEmail = task({
  id: "send-welcome-email",
  retry: {
    maxAttempts: 3,
    minTimeoutInMs: 1000,
    maxTimeoutInMs: 10000,
    factor: 2,
  },
  run: async (payload: { userId: string; email: string }) => {
    const user = await db.users.findById(payload.userId);

    await sendEmail({
      to: user.email,
      template: "welcome",
    });

    await createDefaultWorkspace(user.id);

    return { sent: true };
  },
});
// Trigger from your API
import { sendWelcomeEmail } from "./trigger/welcome-email";

await sendWelcomeEmail.trigger({
  userId: "user_123",
  email: "[email protected]",
});

What Makes Trigger.dev Different

Trigger.dev v3 runs your code in isolated containers, which means you get full Node.js runtime access — file system, native modules, long execution times (up to 5 minutes on free, longer on paid). This makes it excellent for heavy processing tasks that would timeout on serverless functions.

It also has first-class support for batch processing:

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

export const processUploads = task({
  id: "process-uploads",
  run: async (payload: { fileIds: string[] }) => {
    const results = await batch.triggerAndWait(
      payload.fileIds.map((id) => ({
        task: processFile,
        payload: { fileId: id },
      }))
    );

    return results;
  },
});

When Trigger.dev Fits

Trigger.dev excels when you need longer execution times, heavy processing (image/video manipulation, PDF generation, AI inference), or when you want the managed experience of Inngest but with more runtime control. The dashboard is excellent — better than anything you would build yourself on top of BullMQ.

We reached for it on projects where jobs regularly exceeded 30 seconds, such as generating reports or processing bulk imports for Trackelio.

The Tradeoffs

Like Inngest, you depend on a third-party platform. The v3 architecture requires deploying your task code to Trigger.dev’s infrastructure, which adds a build step. The pricing is per-compute-second, which can be unpredictable for variable workloads.

Automation workflow connecting multiple processing stages

Decision Framework

After using all three across multiple projects, here is how we decide:

Choose BullMQ when:

  • You already run Redis
  • You need high throughput at low cost
  • You want full control over infrastructure
  • You are deploying to VMs or containers, not serverless
  • You need sub-second job pickup latency

Choose Inngest when:

  • You deploy on Vercel, Netlify, or similar serverless platforms
  • You want zero infrastructure to manage
  • Your jobs are primarily event-driven workflows with multiple steps
  • Your team is small and ops overhead matters
  • Jobs complete in under 60 seconds

Choose Trigger.dev when:

  • Jobs need long execution times (minutes, not seconds)
  • You do heavy processing (file manipulation, AI, reports)
  • You want managed infrastructure but need full Node.js runtime
  • You need good observability out of the box
  • Batch processing is a core use case

What We Actually Run

For most of our projects, including LancerSpace and MindHyv, we start with Inngest. The step function model and zero-infrastructure setup let us move fast. When a project grows to the point where we need more control or costs become a concern, we evaluate whether migrating to BullMQ makes sense.

Trigger.dev earns its place when jobs are computationally heavy. PDF generation, bulk data exports, and anything involving file processing goes there.

The honest answer is that all three are good. The mistake is not picking the wrong one — it is building a custom job system from scratch using setTimeout and database polling. We have inherited codebases that did that, and the cleanup is never fun.

If you are building something that needs reliable background processing, start with the simplest option that fits your deployment model and upgrade when the constraints actually bite. Over-engineering your job system on day one is time stolen from building features your users care about.

If you are building something similar, reach out at [email protected].