·QueueHub Team·16 min read

Mastering BullMQ Rate Limiting: From Basic Throttling to Per-Group Controls

BullMQRate LimitingRedisNode.jsJob QueuesBackend Engineering

Your third-party API is breathing down your neck — 429s are piling up, your workers are hammering a database that can't keep up, and one noisy tenant is starving everyone else in the queue. If this sounds familiar, you've come to the right place.

BullMQ rate limiting is one of the most powerful — and most misunderstood — features in the BullMQ ecosystem. Whether you're protecting downstream APIs from overload, ensuring fair resource distribution across tenants, or preventing runaway job processing from overwhelming your infrastructure, BullMQ's built-in rate limiter gives you production-grade control without additional infrastructure.

In this post, we'll cover:

  • How BullMQ's rate limiter actually works (and what "token bucket" really means)
  • Worker-level rate limiter patterns for open-source BullMQ
  • Dynamic rate limiting for handling external 429 responses
  • Per-group rate limiting with BullMQ Pro for multi-tenant isolation
  • Utility methods, monitoring, common pitfalls, and best practices

By the end, you'll know exactly how to configure, tune, and monitor rate limiting for any BullMQ workload.


Understanding BullMQ's Rate Limiter Architecture

How Rate Limiting Works Under the Hood

BullMQ's rate limiter uses a fixed-window counter stored in Redis. Here's the mechanism:

  1. Each time a worker processes a job, a Redis counter is incremented.
  2. The counter has a TTL equal to the configured duration.
  3. When the counter hits max, workers stop fetching new jobs until the TTL expires.
  4. When the TTL expires, the counter resets automatically — no manual cleanup needed.

Important clarification: BullMQ's documentation sometimes refers to this as a "token bucket" algorithm. In practice, the current implementation is closer to a fixed-window rate limiter. Jobs are not moved to a delay set (as they were in BullMQ v1/v2); instead, workers simply stop polling. This is a major improvement — BullMQ < 3.0 moved jobs to a delay set and back, causing high Redis CPU under load.

Because the counter lives in Redis, rate limiting works across all worker instances automatically. Deploy ten workers across ten servers? The rate limit is still enforced collectively.

The RateLimiterOptions Interface

interface RateLimiterOptions {
  max: number;      // Max jobs to process in the time window
  duration: number; // Time window in milliseconds
}

Both properties are required. Common configurations:

// 100 req/sec
{ max: 100, duration: 1000 }

// 5,000 req/minute
{ max: 5000, duration: 60000 }

// 50,000 req/hour
{ max: 50000, duration: 3600000 }

Concurrency vs. Rate Limiting — A Critical Distinction

This is the #1 source of confusion with BullMQ rate limiting:

  • Concurrency controls how many jobs can run simultaneously per worker.
  • Rate limiting controls how many jobs can be started per unit of time across all workers.

They work independently but interact in important ways:

  • High concurrency + low rate limit → jobs start fast but queue up in "active" state.
  • Low concurrency + high rate limit → the rate limit budget may never be fully used.
  • You need enough concurrency to saturate the rate limit budget.
const worker = new Worker('api-calls', processor, {
  connection,
  limiter: { max: 100, duration: 1000 }, // 100 jobs/sec across all workers
  concurrency: 50,                         // Up to 50 simultaneous jobs per worker
});
// If API calls take 200ms avg: 1 worker × 50 concurrency can handle 250 jobs/sec
// But rate limiter caps at 100 — concurrency of ~20 would be sufficient

Basic Rate Limiter Patterns (Open-Source BullMQ)

Simple Worker-Level Rate Limiter

The most common use case: limit API calls to an external service.

import { Worker, Queue } from 'bullmq';
import { Redis } from 'ioredis';

const connection = new Redis({
  host: 'localhost',
  port: 6379,
  maxRetriesPerRequest: null,
});

const queue = new Queue('api-calls', { connection });

const worker = new Worker(
  'api-calls',
  async (job) => {
    return await callExternalApi(job.data.endpoint, job.data.payload);
  },
  {
    connection,
    limiter: { max: 300, duration: 1000 },
    concurrency: 50,
  }
);

Smoothing Bursts with Smaller Windows

A fixed-window limiter has a natural burst behavior: at the start of every window, max jobs can fire simultaneously. If your downstream service can't handle bursts, smooth the traffic:

// Process ~1 job every ~3.33ms instead of 300 in a burst
const worker = new Worker('smooth-queue', processor, {
  connection,
  limiter: {
    max: 1,
    duration: 3.33,  // 1000ms / 300 ≈ 3.33ms
  },
});

This spreads processing evenly across the window instead of firing all 300 jobs at once.

Combining Rate Limiting with Retries and Backoff

Rate limiting and retry backoff serve different purposes and compose naturally:

  • Rate limits throttle before a job is picked up.
  • Backoff delays after a job fails.
  • Together they create resilient pipeline.
const worker = new Worker('resilient-api', async (job) => {
  return await callExternalApi(job.data);
}, {
  connection,
  limiter: { max: 300, duration: 1000 },
  concurrency: 50,
  attempts: 5,
  backoff: {
    type: 'exponential',
    delay: 2000,  // 2s, 4s, 8s, 16s, 32s
  },
});

Multiple Queues for Multiple Services

When different downstream services have different rate limits, use separate queues:

class MultiServiceQueueManager {
  private queues: Map<string, { queue: Queue; worker: Worker }> = new Map();

  private readonly SERVICE_CONFIGS = {
    stripe:   { max: 100,  duration: 1000 },
    sendgrid: { max: 50,   duration: 1000 },
    twilio:   { max: 10,   duration: 1000 },
    openai:   { max: 3000, duration: 60_000 }, // 3K RPM
  };

  constructor(private connection: Redis) {
    for (const [service, limiter] of Object.entries(this.SERVICE_CONFIGS)) {
      const queue = new Queue(service, { connection });
      const worker = new Worker(service, this.createProcessor(service), {
        connection,
        limiter,
        concurrency: this.calculateConcurrency(limiter),
      });
      this.queues.set(service, { queue, worker });
    }
  }

  async addJob(service: string, data: unknown) {
    const entry = this.queues.get(service);
    if (!entry) throw new Error(`Unknown service: ${service}`);
    return entry.queue.add('job', data);
  }

  private calculateConcurrency(limiter: { max: number; duration: number }) {
    // Assumes ~200ms avg job duration
    const jobsPerSecPerSlot = 1000 / 200;
    const maxJobsPerSec = (limiter.max / limiter.duration) * 1000;
    return Math.ceil((maxJobsPerSec / jobsPerSecPerSlot) * 1.2);
  }

  private createProcessor(service: string) {
    return async (job: any) => {
      // Service-specific processing logic
    };
  }

  async shutdown() {
    for (const { worker } of this.queues.values()) {
      await worker.close();
    }
  }
}

Dynamic Rate Limiting

The rateLimit() Method and RateLimitError

Sometimes you don't know the rate limit in advance — an external API returns HTTP 429 with a Retry-After header. BullMQ handles this with worker.rateLimit().

import { Worker } from 'bullmq';

const worker = new Worker(
  'external-api',
  async (job) => {
    const response = await callExternalApi(job.data);

    if (response.status === 429) {
      const retryAfterMs = (response.headers['retry-after'] || 60) * 1000;
      await worker.rateLimit(retryAfterMs);

      // ALWAYS throw RateLimitError after rateLimit()
      // This tells BullMQ to move the job back to "waiting" (not "failed")
      throw Worker.RateLimitError();
    }

    return response.data;
  },
  {
    connection,
    // Even with dynamic rate limiting, you must provide a static limiter config
    limiter: { max: 100, duration: 1000 },
  }
);

Why You Must Provide a Static Limiter Config

This trips up many developers: without a static limiter config, rateLimit() calls are silently ignored. BullMQ uses limiter.max internally to decide whether to run rate-limit validation logic. The static config acts as a "default" ceiling; dynamic calls override it temporarily.

Real-World Pattern: Adaptive Throttle Handler

class AdaptiveRateLimiter {
  private worker: Worker;
  private currentBackoff = 0;

  constructor(queueName: string, connection: Redis) {
    this.worker = new Worker(
      queueName,
      async (job) => this.processWithAdaptiveThrottle(job),
      {
        connection,
        limiter: { max: 100, duration: 1000 },
        concurrency: 20,
      }
    );
  }

  private async processWithAdaptiveThrottle(job: any) {
    try {
      return await callExternalApi(job.data);
    } catch (err: any) {
      if (err.status === 429 || err.code === 'RATE_LIMITED') {
        const backoff = err.retryAfterMs || 60_000;
        const effectiveBackoff = this.currentBackoff > 0
          ? Math.min(this.currentBackoff * 2, 300_000)
          : backoff;

        this.currentBackoff = effectiveBackoff;
        await this.worker.rateLimit(effectiveBackoff);
        throw Worker.RateLimitError();
      }
      throw err;
    }
  }

  resetBackoff() {
    this.currentBackoff = 0;
  }
}

Group Rate Limiter (BullMQ Pro)

What Is the Group Rate Limiter?

The Group Rate Limiter is a BullMQ Pro feature (requiring @taskforcesh/bullmq-pro). Each group — for example, a user, tenant, or customer ID — gets its own independent rate limit. Groups that exceed their limit are paused individually; non-rate-limited groups continue processing normally.

When to use groups:

  • Multi-tenant SaaS: each customer has a per-user API rate limit.
  • Fair scheduling: prevent one noisy user from consuming all queue capacity.
  • API key management: different API keys have different rate tiers.

History note: In open-source BullMQ < v3.0, groupKey was available for group rate limiting. It was removed in v3.0 to improve global rate limiter performance. Group rate limiting was reintroduced in a more robust form in BullMQ Pro.

Configuration

import { WorkerPro, QueuePro } from '@taskforcesh/bullmq-pro';

const connection = new Redis({ host: 'localhost', port: 6379 });

const queue = new QueuePro<ApiJobData>('api-calls', { connection });

// Add jobs with a group ID
await queue.add('call-api', {
  userId: 'user_abc123',
  endpoint: '/v2/orders',
  payload: { limit: 50 },
}, {
  group: { id: 'user_abc123' },
});

// Worker with per-group rate limit
const worker = new WorkerPro<ApiJobData>(
  'api-calls',
  async (job) => {
    return await callApi(job.data.endpoint, job.data.payload);
  },
  {
    connection,
    group: {
      limit: {
        max: 10,         // 10 jobs per second
        duration: 1000,  // Per group
      },
    },
    concurrency: 100,
  }
);

How Group Rate Limiting Works

Each group maintains its own Redis counter key. When group A hits its limit, only group A's jobs are paused — groups B and C continue processing. This is a significant architectural difference from the global limiter.

Time 0s:  Group A (5/10) ✅  Group B (2/10) ✅  Group C (3/10) ✅
Time 2s:  Group A (10/10) 🔴  Group B (4/10) ✅  Group C (5/10) ✅
Time 4s:  Group A (10/10) 🔴  Group B (8/10) ✅  Group C (7/10) ✅
Time 6s:  Group A (0/10)  ✅  Group B (10/10)🔴  Group C (9/10) ✅

Dynamic Rate Limiting with Groups

You can dynamically rate-limit individual groups based on upstream responses:

const worker = new WorkerPro(
  'api-calls',
  async (job) => {
    const groupId = job.opts.group.id;
    const response = await callExternalApi(job.data);

    if (response.status === 429) {
      // Rate limit only this group, not the entire queue
      await worker.rateLimitGroup(job, response.retryAfterMs);
      throw Worker.RateLimitError();
    }

    return response.data;
  },
  { connection }
);

Checking Group Rate Limit TTL

import { QueuePro } from '@taskforcesh/bullmq-pro';

const queue = new QueuePro('api-calls', { connection });
const ttl = await queue.getGroupRateLimitTtl('user_abc123', 10);

if (ttl > 0) {
  console.log(`Group user_abc123 is rate limited for ${ttl}ms`);
}

Combining Global + Group Rate Limits

You can apply both a global ceiling and per-group limits simultaneously:

const worker = new WorkerPro(
  'api-calls',
  async (job) => process(job),
  {
    connection,
    // Global rate limit: 1000 jobs/sec across ALL groups
    limiter: { max: 1000, duration: 1000 },
    // Per-group rate limit: 10 jobs/sec per group
    group: {
      limit: { max: 10, duration: 1000 },
    },
    concurrency: 200,
  }
);

Utility Methods for Rate Limit Management

getRateLimitTtl() — Check If the Queue Is Rate Limited

import { Queue } from 'bullmq';

const queue = new Queue('my-queue', { connection });
const ttl = await queue.getRateLimitTtl(100);
// Returns 0 if NOT rate limited (i.e., 100 jobs have NOT been processed yet)
// Returns >0 if rate limited — the remaining milliseconds of the window

if (ttl > 0) {
  console.log(`Queue is rate limited. Resume in ${ttl}ms`);
}

Use this for dashboard widgets, proactive alerting, or conditional job submission logic.

removeRateLimitKey() — Emergency Reset

const queue = new Queue('my-queue', { connection });
await queue.removeRateLimitKey(); // Resets rate limit counter to zero

Use this for manual intervention when the rate limit configuration is too conservative and you need to unblock processing. Use with caution — it can cause downstream overload.


Monitoring Rate Limits with Queue Hub

Rate limiting is invisible in logs — jobs simply don't get picked up. Distinguishing "no jobs in the queue" from "jobs are waiting but rate limited" requires active monitoring. This is where Queue Hub shines:

  • Throughput charts: See when rate limits kick in — dips in processed jobs.
  • Processing time metrics: Correlate rate limits with job duration.
  • Job detail view: Check if jobs are spending excessive time in "waiting" due to rate limiting.
  • Live worker view: Observe worker states — idle workers may indicate active rate limiting.

Queue Hub gives you end-to-end visibility into rate limiting behavior without custom instrumentation. You can set up alerting on rate limit TTL thresholds to proactively manage capacity.


Common Pitfalls and How to Avoid Them

Pitfall Symptom Fix
Not setting concurrency high enough Rate limit budget never fully used Calculate: concurrency = ceil((max/duration) * avgJobTime * 1.2)
Confusing concurrency with rate limits Jobs pile up in "active" despite low rate limit These are orthogonal controls — tune both
No static limiter config for dynamic rateLimit() rateLimit() silently does nothing Always include limiter: { max: N, duration: N }
Not throwing RateLimitError after rateLimit() Job goes to "failed" instead of "waiting" Always throw Worker.RateLimitError()
Fixed-window burst behavior Traffic spikes at window boundaries Use shorter windows (max: 1, duration: 3.33)
Assuming groupKey works in BullMQ v3+ Compile errors groupKey was removed — use BullMQ Pro
Rate limit TTL persists across restarts Workers remain paused after restart Call removeRateLimitKey() on initialization if safe
Inconsistent limiter config across workers Rate limits don't apply uniformly All workers for the same queue must use the same limiter config
Matching rate limit exactly to API limit 429 errors despite "proper" rate limiting Set max at 80–90% of documented API limit for headroom

Best Practices

  1. Set limits below actual capacity — Leave 10–20% headroom below external API limits.
  2. Calculate concurrency to fully utilize the rate limit budget:
function optimalConcurrency(rateLimitPerSec: number, avgJobTimeMs: number): number {
  return Math.ceil(((rateLimitPerSec * avgJobTimeMs) / 1000) * 1.2);
}
// Example: 100 req/sec, 300ms avg -> ceil((100 * 300) / 1000 * 1.2) = 36
  1. Use shorter windows for tighter controlmax: 10, duration: 6000 instead of max: 100, duration: 60000 reduces burst window size.
  2. Combine rate limiting with exponential backoff for transient failures.
  3. Monitor both getRateLimitTtl() and queue depth to distinguish "rate limited" from "empty queue."
  4. Document rate limit configurations in your deployment manifests.
  5. Test with production-like traffic patterns — bursts and idle periods reveal tuning issues.
  6. Use separate queues for services with different rate limits rather than one monolithic queue.
  7. In multi-tenant setups, consider group rate limiting (Pro) to prevent tenant starvation.
  8. Add alerting on rate limit events — a queue that's constantly rate limited may need a higher limit or downstream capacity increase.

Comparison with Other Rate Limiting Approaches

Approach Pros Cons Best For
BullMQ Worker Limiter Built-in, Redis-backed, distributed, no extra infra Fixed window only, queue-level (not per-key in OSS) Job queues calling rate-limited external APIs
BullMQ Pro Group Limiter Per-group fairness, combines with global, dynamic Requires Pro license Multi-tenant SaaS with per-user rate limits
External API Gateway (Kong, AWS API GW) Centralized, per-route, per-IP, rich features Extra infrastructure, not queue-aware HTTP API exposure (frontend-facing)
Redis-based custom (rate-limiter-flexible) Highly flexible, multiple algorithms Requires custom implementation Non-queue workloads, complex logic
In-process (bottleneck, p-limit) Simple, no Redis dependency Not distributed, lost on restart Small-scale single-server apps

The BullMQ advantage: Rate limiting is native to the job lifecycle. Jobs that are rate-limited stay in "waiting" state — they don't need special handling, don't go to "failed" or "delayed" (unless dynamically limited), and resume automatically. Combined with Queue Hub's visual dashboard, you get end-to-end visibility without custom instrumentation.


Complete Production Example — Shopify Webhook Worker

Let's tie everything together with a realistic example: a Shopify app that processes webhooks with proper rate limiting, 429 handling, and graceful shutdown:

import { Worker, Queue, Job } from 'bullmq';
import { Redis } from 'ioredis';

// Configuration
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const SHOPIFY_API_LIMIT = 40;  // Shopify allows 40 req/sec per app
const AVG_API_DURATION_MS = 250;

const connection = new Redis(REDIS_URL, { maxRetriesPerRequest: null });

// Queue
const queue = new Queue('shopify-webhooks', { connection });

// Worker with rate limiting
const worker = new Worker(
  'shopify-webhooks',
  async (job: Job) => {
    const response = await fetch(job.data.shopifyEndpoint, {
      method: 'POST',
      headers: {
        'X-Shopify-Access-Token': job.data.accessToken,
      },
      body: JSON.stringify(job.data.payload),
    });

    if (response.status === 429) {
      const retryAfter = parseInt(
        response.headers.get('Retry-After') || '1',
        10
      ) * 1000;
      await worker.rateLimit(retryAfter);
      throw Worker.RateLimitError();
    }

    if (!response.ok) {
      throw new Error(`Shopify API error: ${response.status}`);
    }

    return response.json();
  },
  {
    connection,
    concurrency: Math.ceil((SHOPIFY_API_LIMIT * AVG_API_DURATION_MS) / 1000 * 1.2),
    limiter: {
      max: SHOPIFY_API_LIMIT,
      duration: 1000,
    },
    attempts: 3,
    backoff: { type: 'exponential', delay: 5000 },
  }
);

// Graceful shutdown
process.on('SIGTERM', async () => {
  await worker.close();
  await queue.close();
  await connection.quit();
});

This example combines static rate limiting (40 req/sec Shopify App limit), dynamic backoff (handling 429 responses), exponential retry backoff (for network failures), tuned concurrency (matching the rate limit budget), and graceful shutdown.


Key Takeaways

  • BullMQ's rate limiter is a Redis-backed fixed-window counter — simple, distributed, and efficient.
  • The limiter config is set on the Worker, not the Queue.
  • Concurrency and rate limiting are independent — tune both to fully utilize your budget.
  • Dynamic rate limiting (rateLimit() + RateLimitError) handles external 429 responses gracefully.
  • Group rate limiting (BullMQ Pro) provides fair per-tenant/per-user throttling.
  • Use getRateLimitTtl() and removeRateLimitKey() for monitoring and emergency reset.
  • Queue Hub visualizes rate limiting impact through throughput charts, processing time metrics, and worker state views — essential for production observability.
  • Common pitfalls (forgotten limiter config, missing RateLimitError, wrong concurrency) have straightforward fixes once you know what to look for.

See your rate limiting in action. Monitor queue throughput, job wait times, and worker states with Queue Hub's real-time dashboard. Start your free trial at queuehub.tech →

Related Articles