Web Engineering ·

Background Jobs in TypeScript: BullMQ, Inngest, and Cloudflare Queues Compared

The background job landscape has fragmented. This guide compares BullMQ, Inngest, and Cloudflare Queues on architecture, DX, failure handling, scaling, and cost, with TypeScript examples for each.

Background Jobs in TypeScript: BullMQ, Inngest, and Cloudflare Queues Compared

Most web apps eventually hit the same wall. A user action takes too long to complete synchronously, so you move it to the background. Email delivery, PDF generation, data exports, webhook fan-out, nightly aggregations. The moment you need background processing, you have to choose a queue system, and that choice shapes your infrastructure for years.

Three years ago, BullMQ was the obvious answer for Node.js. Today the landscape has fractured into at least three distinct philosophies, each with real tradeoffs worth understanding before you commit.

This article compares:

  • BullMQ: Redis-backed, battle-tested, runs in your own infrastructure.
  • Inngest: Event-driven serverless functions with a managed orchestration layer.
  • Cloudflare Queues: Edge-native message queue tied to the Workers runtime.

The comparison focuses on what actually matters when you are shipping TypeScript: job definition ergonomics, failure handling, retry semantics, observability, scaling behavior, and cost model.


The Core Problem Each Tool Solves

Before the code, it helps to understand what mental model each tool is built around.

BullMQ thinks in terms of workers and queues. You enqueue a job, a worker picks it up, executes it, and reports success or failure. The queue is durable (Redis), the workers are processes you run and scale yourself. This is the classical producer-consumer pattern, ported cleanly to Node.js.

Inngest thinks in terms of events and functions. You send an event, and any function that listens to that event executes in response. Inngest manages the execution, retries, and state for you. Your functions run inside your own HTTP server (Next.js, Express, whatever), but Inngest orchestrates when and how they run.

Cloudflare Queues thinks in terms of messages and consumers. Producers send messages to a queue. A Worker script consumes batches of messages. The queue is durable, delivery is at-least-once, and everything runs on Cloudflare’s edge network. If you are already on Workers, the integration is near-zero friction.


BullMQ

Architecture

BullMQ uses Redis as the queue store. Jobs are serialized to Redis, and workers use blocking list operations (BRPOPLPUSH) to claim jobs atomically. The library handles delayed jobs, repeatable jobs (cron), job priorities, rate limiting, and concurrency all at the Redis layer.

Your workers are long-running Node.js processes. They can run on bare metal, EC2, containers, or Kubernetes. You scale workers horizontally by adding more processes.

Job Definition

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

const connection = new IORedis({ host: "localhost", port: 6379 });

// Define the job payload type
interface EmailJobData {
  to: string;
  subject: string;
  templateId: string;
  variables: Record<string, string>;
}

// Producer: enqueue a job
const emailQueue = new Queue<EmailJobData>("email", { connection });

await emailQueue.add(
  "send-welcome",
  {
    to: "user@example.com",
    subject: "Welcome aboard",
    templateId: "welcome-v2",
    variables: { firstName: "Alex" },
  },
  {
    attempts: 5,
    backoff: { type: "exponential", delay: 2000 },
    removeOnComplete: { count: 1000 },
    removeOnFail: { count: 5000 },
  }
);

// Consumer: process jobs
const worker = new Worker<EmailJobData>(
  "email",
  async (job: Job<EmailJobData>) => {
    const { to, subject, templateId, variables } = job.data;
    await sendEmail({ to, subject, templateId, variables });
    return { sentAt: new Date().toISOString() };
  },
  {
    connection,
    concurrency: 10,
  }
);

worker.on("failed", (job, error) => {
  console.error(`Job ${job?.id} failed after ${job?.attemptsMade} attempts`, error);
});

Repeatable Jobs and Scheduling

BullMQ supports cron-style repeatable jobs natively.

await emailQueue.add(
  "digest-email",
  { type: "weekly-digest" },
  {
    repeat: { pattern: "0 9 * * 1" }, // Every Monday at 09:00
    jobId: "weekly-digest", // Dedup key
  }
);

This is stored in Redis. No external scheduler required.

Failure Handling

BullMQ gives you fine-grained control over retry behavior per job. The backoff option supports fixed and exponential strategies. You can also implement onFailed callbacks to send alerts, dead-letter to another queue, or trigger compensating actions.

const worker = new Worker<EmailJobData>(
  "email",
  async (job) => {
    if (job.attemptsMade > 0) {
      console.log(`Retry attempt ${job.attemptsMade} for job ${job.id}`);
    }
    await sendEmail(job.data);
  },
  { connection, concurrency: 5 }
);

// Move failed jobs to a dead-letter queue for inspection
worker.on("failed", async (job, err) => {
  if (job && job.attemptsMade >= job.opts.attempts!) {
    await deadLetterQueue.add("failed-email", {
      originalJob: job.data,
      error: err.message,
      failedAt: new Date().toISOString(),
    });
  }
});

Observability

BullMQ ships a companion UI called Bull Board (and the official BullMQ Pro includes a hosted dashboard). For open-source setups, Bull Board integrates with Express or Next.js in minutes and gives you a visual queue browser, job status, retry controls, and worker health.

import { createBullBoard } from "@bull-board/api";
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
import { ExpressAdapter } from "@bull-board/express";

const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath("/admin/queues");

createBullBoard({
  queues: [new BullMQAdapter(emailQueue)],
  serverAdapter,
});

app.use("/admin/queues", serverAdapter.getRouter());

Inngest

Architecture

Inngest is a different mental model entirely. Your application exposes an HTTP endpoint (/api/inngest). Inngest’s platform calls that endpoint when it wants your functions to execute. Your functions are not long-running workers; they are request handlers that Inngest orchestrates.

This means: no process management, no Redis to provision, no worker scaling. The tradeoff is that you are now dependent on Inngest’s platform for execution coordination, and your functions must be idempotent because Inngest may call them multiple times during multi-step execution.

Function Definition

import { Inngest } from "inngest";
import { serve } from "inngest/next"; // or express, hono, etc.

const inngest = new Inngest({ id: "my-app" });

// Define an event type
type UserSignedUp = {
  name: "user/signed-up";
  data: {
    userId: string;
    email: string;
    plan: "free" | "pro";
  };
};

// Define a function that responds to the event
const sendWelcomeEmail = inngest.createFunction(
  {
    id: "send-welcome-email",
    retries: 5,
  },
  { event: "user/signed-up" },
  async ({ event, step }) => {
    // step.run is retried independently if it fails
    const user = await step.run("fetch-user", async () => {
      return await db.users.findById(event.data.userId);
    });

    await step.run("send-email", async () => {
      await emailProvider.send({
        to: event.data.email,
        template: "welcome",
        variables: { name: user.name },
      });
    });

    // Wait for an event before continuing (durable sleep)
    const upgraded = await step.waitForEvent("user-upgraded", {
      event: "user/plan-upgraded",
      match: "data.userId",
      timeout: "7d",
    });

    if (upgraded) {
      await step.run("send-upgrade-confirmation", async () => {
        await emailProvider.send({
          to: event.data.email,
          template: "upgrade-confirmed",
        });
      });
    }
  }
);

// Expose the endpoint
export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [sendWelcomeEmail],
});

The Step Primitive

The most distinctive feature of Inngest is step. Each step.run call is checkpointed. If your function fails mid-execution, Inngest replays it from the last successful step, not from the beginning. This gives you durable execution without managing state yourself.

This is a genuine capability difference from BullMQ. With BullMQ, you get one job execution per attempt. If your job is halfway done when it fails, you restart from scratch (and need to write your own idempotency guards). Inngest’s step model handles this natively.

const processOrder = inngest.createFunction(
  { id: "process-order", retries: 3 },
  { event: "order/placed" },
  async ({ event, step }) => {
    // Each step is independently retried and checkpointed
    const payment = await step.run("charge-card", () =>
      stripe.charges.create({ amount: event.data.amountCents, currency: "usd" })
    );

    const fulfillment = await step.run("create-fulfillment", () =>
      fulfillmentService.create({ orderId: event.data.orderId, chargeId: payment.id })
    );

    await step.sleep("wait-for-shipping", "2d");

    await step.run("send-tracking-email", () =>
      emailProvider.send({ to: event.data.email, template: "tracking" })
    );
  }
);

Failure Handling

Inngest retries automatically based on the retries config. Each step retries independently. You can also use step.waitForEvent with timeouts to handle cases where downstream systems are slow, without burning retry budget.

For dead-lettering, Inngest’s dashboard shows all failed function runs with the full execution trace, which step failed, how many times, and the exact error. This is significantly better than raw BullMQ for debugging multi-step failures.


Cloudflare Queues

Architecture

Cloudflare Queues is a managed message queue baked into the Workers platform. A producer Worker sends messages to a queue. A consumer Worker receives batches of messages and processes them. Delivery is at-least-once. Messages are retained for up to 4 days.

If you are already building on Cloudflare Workers, Queues adds near-zero operational overhead. No Redis, no separate infrastructure, no process management. The queue is configured in wrangler.toml.

If you are not on Workers, Queues is not a practical option. It is deeply tied to the Workers runtime.

Configuration

# wrangler.toml
name = "my-worker"

[[queues.producers]]
queue = "email-jobs"
binding = "EMAIL_QUEUE"

[[queues.consumers]]
queue = "email-jobs"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 3
dead_letter_queue = "email-jobs-dlq"

Producer

// producer-worker/index.ts
export interface Env {
  EMAIL_QUEUE: Queue<EmailMessage>;
}

interface EmailMessage {
  to: string;
  subject: string;
  templateId: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const body = await request.json<{ email: string }>();

    await env.EMAIL_QUEUE.send({
      to: body.email,
      subject: "Welcome",
      templateId: "welcome-v2",
    });

    return new Response(JSON.stringify({ queued: true }), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

Consumer

// consumer-worker/index.ts
export interface Env {
  EMAIL_QUEUE: Queue<EmailMessage>;
}

export default {
  async queue(
    batch: MessageBatch<EmailMessage>,
    env: Env
  ): Promise<void> {
    for (const message of batch.messages) {
      try {
        await sendEmail(message.body);
        message.ack();
      } catch (error) {
        // Retry this specific message; others in the batch are unaffected
        message.retry();
      }
    }
  },
};

Failure Handling

Cloudflare Queues supports per-message retry with message.retry() and message.ack(). A dead-letter queue handles messages that exhaust their retry budget. The retry delay is configurable in wrangler.toml but is less flexible than BullMQ’s per-job backoff configuration.

One notable limitation: Cloudflare Queues does not support cron-style scheduling natively. You use Cron Triggers on a separate Worker to enqueue messages on a schedule.

// scheduler-worker/index.ts
export default {
  async scheduled(event: ScheduledEvent, env: Env): Promise<void> {
    await env.EMAIL_QUEUE.send({
      type: "weekly-digest",
      scheduledAt: new Date().toISOString(),
    });
  },
};

Side-by-Side Comparison

DimensionBullMQInngestCloudflare Queues
InfrastructureRedis (self-managed or hosted)Managed (Inngest platform)Managed (Cloudflare)
RuntimeAny Node.js environmentAny HTTP serverCloudflare Workers only
Job definitionQueue + Worker patternEvent + Function patternProducer + Consumer pattern
Retry granularityPer-job, custom backoffPer-step, independent retriesPer-message, configurable
Durable multi-stepManual (idempotency required)Native (step checkpoints)Not supported
SchedulingNative cron (Redis-backed)step.sleep, step.waitForEventCron Triggers on separate Worker
ObservabilityBull Board (self-hosted)Managed dashboard with tracesCloudflare dashboard
Local devLocal Redis requiredDev server with no cloud depswrangler dev with local queue
PricingRedis cost onlyFree tier + usage-basedPer-message pricing
Vendor lock-inLow (Redis is portable)High (Inngest-specific primitives)High (Workers only)
Scaling workersManual (horizontal process scaling)Automatic (serverless)Automatic (Workers scale)
Throughput ceilingVery high (Redis limits)Platform limits apply5,000 messages/second per queue

Production Considerations

Idempotency

All three systems use at-least-once delivery. You must design your jobs to be safe to execute more than once.

For BullMQ, the typical pattern is a dedup key stored in your database before any side effects.

async function processPayment(jobData: PaymentJobData) {
  const existing = await db.payments.findByJobId(jobData.jobId);
  if (existing) return existing; // Already processed

  return await db.payments.create({
    jobId: jobData.jobId,
    amount: jobData.amount,
    status: "completed",
  });
}

For Inngest, the step model handles most of this automatically. Each step only executes once unless it fails. But side effects inside a step must still be idempotent if that step retries.

For Cloudflare Queues, message.ack() only succeeds once per message. If your consumer crashes after processing but before acking, the message redelivers. Guard with a database check.

Memory and CPU in Workers

BullMQ workers are long-running processes. You control memory limits, CPU allocation, and worker restarts. This is flexible and familiar.

Inngest functions run inside your existing HTTP server. They are subject to your hosting platform’s request timeout limits (Vercel: 10s on hobby, 60s on pro, 300s on enterprise). Long-running jobs need to be broken into steps.

Cloudflare Workers have a 30-second CPU time limit per invocation (128MB memory). This is a hard constraint. Anything that takes longer needs to fan out to multiple messages.

Observability in Production

BullMQ with Bull Board gives you job counts, active workers, failed jobs, and retry state. You lose structured execution traces. For debugging, you rely on your own logging.

Inngest’s dashboard shows every function run, every step, and the input/output of each step. This is significantly better for debugging complex workflows. You can see exactly what happened without digging through logs.

Cloudflare Queues exposes metrics in the Workers dashboard (messages delivered, acked, retried, dead-lettered). There are no per-message traces, but you can use Workers Logpush to get structured logs.

Cost Model Reality

BullMQ: You pay for Redis. Upstash charges per command; Redis Cloud and ElastiCache charge per node-hour. For low-volume apps, Upstash is near-free. For high-throughput systems, Redis cost is predictable and scales linearly with usage.

Inngest: The free tier covers 50,000 function runs per month. Beyond that, pricing is per function run and per step. For workflows with many steps, costs add up fast. The managed execution tradeoff may be worth it, but model your usage before committing.

Cloudflare Queues: Priced per million message operations. The free tier covers 1 million operations per month. For most small-to-medium apps, this is free. At high volume, the cost is competitive but you need to account for Workers compute costs too.


When to Use Each

Use BullMQ when:

  • You are running on your own servers or a VPS, not serverless.
  • You need maximum control over concurrency, worker scaling, and job priorities.
  • You have high throughput requirements and want predictable Redis-based costs.
  • You need mature ecosystem tooling (Bull Board, BullMQ Pro, extensive documentation).
  • You are comfortable managing a Redis instance.

Use Inngest when:

  • You are on a serverless platform (Vercel, Netlify, Railway) and cannot run persistent workers.
  • Your jobs have multiple steps with dependencies between them.
  • You need durable execution without writing your own checkpointing logic.
  • Observability and debugging ergonomics are a priority.
  • You are comfortable with vendor dependency and want to minimize infrastructure.

Use Cloudflare Queues when:

  • You are already building on Cloudflare Workers.
  • Your jobs complete within the Workers CPU time limit.
  • You want zero infrastructure overhead and are not doing complex multi-step workflows.
  • Edge-native latency matters for your producer path.

The Real Tradeoff

The choice between these three is not really about features. They all deliver messages and retry failures. The choice is about what you are willing to own.

BullMQ asks you to own your infrastructure (Redis, worker processes, scaling). In return, you get portability, maximum throughput, and no vendor lock-in.

Inngest asks you to accept a vendor dependency in exchange for eliminating infrastructure entirely and getting first-class durable execution semantics.

Cloudflare Queues asks you to commit to the Workers ecosystem. Within that boundary, it is the simplest possible path to reliable background processing.

Senior engineers often default to BullMQ because it is familiar and proven. That default is reasonable. But if you are on Vercel and your jobs have complex multi-step logic, BullMQ is actually the harder path. You will spend real time making serverless-hostile worker processes work. Inngest exists specifically to solve that.

Pick the tool that matches your deployment model first. Then evaluate features.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.