·QueueHub Team·6 min read

BullMQ Rate Limiting: When to Use limiter and How It Works

BullMQRedisrate limitingqueue managementworker design

Most BullMQ teams discover rate limiting the hard way: a downstream API starts returning 429s, a third-party bill spikes, or a customer's webhook endpoint collapses under a burst. When several workers process the same queue, the aggregate request rate is the sum of every worker's concurrency — and nothing else in BullMQ stops that total from overwhelming an external system. The limiter option is the built-in answer: a per-queue cap on how fast jobs are handed to workers, enforced in Redis and shared across every worker you run.

BullMQ rate limiting is one of the library's most useful options and one of the least understood. This post covers how it works internally, when it actually helps, per-tenant limits with groupKey, and the mistakes that make people think it's broken.

How BullMQ Rate Limiting Works

Rate limiting is configured per queue, not per worker. You pass a limiter object when you create the queue, and every worker connected to that queue cooperates on the same budget — even when your workers are spread across several processes or machines:

import { Queue, Worker } from "bullmq";

const queue = new Queue("webhook-delivery", {
  connection: { host: "127.0.0.1", port: 6379 },
  limiter: {
    max: 100,       // at most 100 jobs
    duration: 1000, // every 1 second
  },
});

const worker = new Worker("webhook-delivery", async (job) => {
  await fetch(job.data.url, { method: "POST" });
}, { connection: { host: "127.0.0.1", port: 6379 } });

Under the hood, BullMQ implements the limit with a Redis sorted set that tracks the timestamps of recently processed jobs. When a worker asks for the next job, the queue checks whether handing one out would exceed max within the rolling duration window. If it would, the worker waits and retries shortly after instead of fetching.

Two consequences follow from this design:

  • The limit is global to the queue. A limiter on the queue object applies to the queue itself, so scaling your workers out does not let you exceed the cap. This is the whole point of enforcing it in the broker rather than in application code.
  • Only fetching is throttled. Adding jobs is never blocked, and jobs waiting for a rate-limit slot stay in the waiting state. You can add 10,000 jobs in a second; they simply trickle out at the configured rate.

When to Use the limiter Option

Use rate limiting whenever the work your jobs perform can hurt something outside the queue:

  • Third-party API quotas — payment providers, CRMs, and marketing APIs return 429/403 when you exceed their limits, and a burst of failed jobs triggers retries that make the problem worse.
  • LLM providers — request-per-minute and token-per-minute limits are easy to blow through when one job fans out into many model calls.
  • Email and SMS sending — providers enforce per-second and per-hour caps, and overages are either rejected or billed at premium rates.
  • Webhook fan-out — when a single event produces notifications for thousands of customers, their endpoints can't absorb an instant burst.
  • Backfills and migrations — jobs that should run hot without saturating the database or API they touch.

A useful rule of thumb: if you have ever written a sleep() or a manual throttle inside a worker, the limiter option is the more reliable replacement. It works across processes, survives worker restarts, and doesn't burn a job's processing time on waiting.

Per-Tenant Limits with groupKey

Sometimes the limit shouldn't apply to the queue as a whole but per customer or tenant. That's what groupKey is for: you name a job property, and BullMQ applies the max/duration window independently for each distinct value of that property:

const queue = new Queue("notifications", {
  connection: { port: 6379 },
  limiter: {
    max: 5,
    duration: 1000,
    groupKey: "userId", // 5 jobs/sec per user, not per queue
  },
});

await queue.add("push", { userId: 42, text: "Hello" });
await queue.add("push", { userId: 7, text: "Hi" });

In this example, user 42 gets their own 5-per-second budget and user 7 gets theirs. A queue-wide limit would be wrong here: one busy tenant would consume the entire budget and starve everyone else. groupKey is the cleanest built-in way to enforce per-tenant fairness in BullMQ.

One caveat: each distinct group value adds Redis bookkeeping, so keep the cardinality bounded. Per-user or per-organization IDs are ideal; per-request IDs or timestamps are a misuse that will bloat memory.

How Rate Limiting Interacts with Priorities and Concurrency

  • Priorities still apply. Within the rate window, waiting jobs are still ordered by priority, so urgent jobs get the next slot. The limiter caps throughput; it doesn't change ordering (see how BullMQ priorities work).
  • Concurrency is a ceiling, not a guarantee. concurrency controls how many jobs a single worker processes in parallel. When the limiter's implied rate is lower than what concurrency would allow, the limiter wins — it throttles at fetch time, before jobs reach your processor.
  • Delayed jobs are separate. A delayed job becomes eligible at its timestamp and then competes for rate-limit slots like any other job.

What Rate Limiting Does Not Do

  • It is not a queue pauser. Jobs keep moving through the queue, just at the capped rate. To stop processing entirely, use pause().
  • It does not protect Redis. The limiter protects whatever your jobs touch downstream; Redis still stores every job you add.
  • It is not visible in the active count. When a queue is throttled, active sits near the rate limit while waiting grows. A queue that looks backed up is often just rate-limited — telling the two apart is exactly what a queue dashboard is for.

If you see a growing backlog, check the limiter configuration before assuming your workers are stalling — the fix for a throttled queue is raising max or adding workers, not restarting anything.

Summary

BullMQ rate limiting is a per-queue budget enforced in Redis and shared across every worker, which makes it the right tool for protecting downstream APIs, provider quotas, and — via groupKey — per-tenant fairness. It throttles at fetch time, so it works regardless of concurrency, and it never blocks enqueueing. Configure it whenever your jobs touch a system with its own limits, and remember that a climbing waiting count with a flat active count usually means the limiter is doing its job.

Related Articles