Skip to content

Cloud & Infrastructure · Backend Architecture

Background Jobs in 2026: BullMQ, Inngest, or Temporal?

Three tools, three different bets on where complexity belongs. Here's how to choose between BullMQ, Inngest, and Temporal based on what your system actually needs — not what sounds most impressive.

Prathviraj Singh

Prathviraj Singh

6 min read

Background Jobs in 2026: BullMQ, Inngest, or Temporal?

Sponsored

Share

Every backend eventually needs to run work outside the request-response cycle. Send a confirmation email. Process an uploaded file. Sync data to a third-party system. Retry a failed webhook. The tools that handle this have multiplied enough that picking one is now a real architectural decision.

BullMQ, Inngest, and Temporal are the three I get asked about most. They solve similar problems with very different approaches, and picking the wrong one creates friction you will feel for years. Here is how to choose.

What each tool actually is

BullMQ is a job queue. You push a job onto a queue, workers pick it up and execute it, and the queue tracks success, failure, and retries. It is backed by Redis, which means it is fast and you can inspect queue state directly. You manage the infrastructure (Redis, your worker processes), and you manage the retry and failure logic in your application.

Inngest is an event-driven function runtime. Instead of a queue you manage, you write functions that run in response to events. Inngest handles the scheduling, retry, and observability layer. Your functions are HTTP endpoints; the Inngest platform calls them. There is no queue server to run.

Temporal is a workflow orchestration engine. You write workflows in code (durable, stateful sequences of steps) and Temporal guarantees they will complete correctly even if your servers crash, your dependencies go down, or the workflow takes months. It persists workflow state externally and replays execution history on recovery. It is its own cluster (or a managed service), and it has real operational weight.

When BullMQ fits

BullMQ is the right default for most applications. Use it when:

  • You are already running Redis (or are willing to add it).
  • Your jobs are discrete, short-running tasks: sending a notification, generating a PDF, processing a webhook, running a scheduled batch.
  • Throughput matters: BullMQ handles tens of thousands of jobs per second on a modest Redis instance.
  • You want simple, inspectable state: Redis data is queryable, job state is visible.

A typical BullMQ setup:

import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";

const connection = new IORedis(process.env.REDIS_URL!);

// Producer — add jobs to the queue
const emailQueue = new Queue("email", { connection });

await emailQueue.add(
  "welcome",
  { userId: "123", to: "user@example.com" },
  {
    attempts: 3,
    backoff: { type: "exponential", delay: 2000 },
  }
);

// Worker — consume and process jobs
const worker = new Worker(
  "email",
  async (job) => {
    await sendEmail(job.data.to, job.data.userId);
  },
  { connection, concurrency: 10 }
);

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

The tradeoff: you manage the worker processes and Redis. If your workers crash during a job, BullMQ can retry it (that is what attempts handles), but you have to think about idempotency: if a job runs twice because of a retry, does it do the right thing?

When Inngest fits

Inngest is the right choice when:

  • You are on a serverless platform (Next.js on Vercel, Remix on Fly, SvelteKit) where managing persistent worker processes is inconvenient.
  • You want the retry and observability layer managed for you without running a queue server.
  • Your jobs are event-driven: something happened, trigger a function.
  • You want step functions — multi-step jobs where each step can be retried independently.

The Inngest model:

import { inngest } from "./client";

export const processSignup = inngest.createFunction(
  { id: "process-signup", retries: 3 },
  { event: "user/signup" },
  async ({ event, step }) => {
    // Each step is retried independently on failure
    await step.run("send-welcome-email", async () => {
      await sendWelcomeEmail(event.data.email);
    });

    await step.sleep("wait-for-activation", "24h");

    await step.run("check-activation", async () => {
      const user = await getUser(event.data.userId);
      if (!user.activated) {
        await sendReminderEmail(event.data.email);
      }
    });
  }
);

What you get: each step is persisted. If check-activation fails, it retries from that step, not from the beginning. The 24h sleep does not consume a running process. Inngest handles the scheduling. You can see every job run, every step, every error in their dashboard.

What you give up: your job handlers must be accessible HTTP endpoints. On a public serverless platform, this is natural. On a private service without a public URL, it requires a tunnel or reverse proxy. You also depend on Inngest’s platform for the coordination layer (they have a self-hosted option, but it adds operational overhead).

When Temporal fits

Temporal is the right choice when your workflow is:

  • Long-running: hours, days, or longer.
  • Stateful: you need to remember what happened across multiple steps.
  • Reliable under arbitrary failures: crashes, restarts, outages should not lose workflow state.
  • Complex: human approval gates, compensating transactions, parallel fan-out and fan-in.

A Temporal workflow for a document approval process:

import { proxyActivities, defineSignal, setHandler, sleep } from "@temporalio/workflow";

const { sendForReview, applyDocument, notifyRejection } = proxyActivities<
  typeof activities
>({ startToCloseTimeout: "30s" });

export const approvedSignal = defineSignal<[{ approved: boolean }]>("approved");

export async function documentApprovalWorkflow(documentId: string): Promise<void> {
  await sendForReview(documentId);

  let approved: boolean | undefined;
  setHandler(approvedSignal, ({ approved: a }) => {
    approved = a;
  });

  // Wait up to 7 days for approval
  await sleep("7 days");

  if (approved === true) {
    await applyDocument(documentId);
  } else {
    await notifyRejection(documentId);
  }
}

Temporal persists the workflow state as an event log. If your server crashes while waiting for approval, the workflow resumes on restart. The 7 days sleep does not hold a thread; it is stored in Temporal’s state and fires when the time comes.

The cost: Temporal is its own distributed system. You need to run the Temporal server (or use Temporal Cloud, which is managed but adds cost). The development model requires understanding replay semantics — your workflow code runs in a deterministic execution engine with specific restrictions. The learning curve is real.

The comparison in one table

BullMQInngestTemporal
InfrastructureRedisNone (or self-hosted)Temporal server
Job durationSeconds to minutesSeconds to daysSeconds to months
State persistenceRedisInngest platformTemporal history
Steps / multi-stageNo (manual chaining)Yes (built-in)Yes (core model)
Serverless-friendlyModerateYes, nativeModerate
Operational burdenLow-mediumLowHigh
Best forHigh-throughput queuesServerless multi-stepLong, stateful workflows

The decision

If you are uncertain, start with BullMQ. It is the simplest option with the most predictable operational behavior. Add Inngest if your environment is serverless and you want managed retries and multi-step support. Add Temporal when BullMQ or Inngest cannot express what you need — specifically, when long-running stateful workflows with recovery guarantees are the requirement.

Most applications only ever need BullMQ. A smaller number benefit from Inngest’s model. Temporal is for the workflows where losing state on a crash is genuinely not an option.

The existing posts on Inngest and Temporal go deep on each tool’s patterns. Read those after deciding which direction fits your system.

Frequently asked questions

What is BullMQ used for?
BullMQ is a Node.js job queue backed by Redis. It handles scheduling, retries, concurrency, and priority queues. It is the right choice when you need a reliable, fast queue for background jobs — email sending, image processing, webhook delivery, scheduled tasks — and you are already running Redis.
When does Temporal make sense over a simple queue?
Temporal is worth the complexity when your workflow is long-running (minutes to months), involves multiple steps that can each fail independently, requires human approval gates, or needs to be resumed after process restarts. For a three-step process that runs in under a second, Temporal is overkill. For a multi-day approval workflow with compensating transactions, it is the right tool.
Is Inngest production-ready?
Yes, as of 2025 it is used in production by a number of mid-size companies. The tradeoffs are real: it requires your job handlers to be HTTP endpoints, which works naturally on serverless but adds a reverse-proxy requirement if your service is not publicly accessible. You also depend on Inngest's platform for the scheduling and retry layer unless you self-host.
Can I switch from BullMQ to Temporal later if my needs grow?
Yes, but it is a migration, not a refactor. The mental models are different enough that you would rewrite your job logic. The data models are also incompatible. Plan for switching cost if you think you might need Temporal's properties eventually — some teams choose to use BullMQ for simple jobs and Temporal for complex workflows in the same system.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored