Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
SaaS and CloudAI and AutomationLaravelNode.jsPHPComparison

Laravel queues vs NestJS BullMQ in 2026: architecture and performance

Laravel queues and BullMQ compared on architecture, retries, rate limits, cancellation and dashboards, with a benchmark on the same Redis and what it means for AI jobs.

Cloudsoft Zone

Updated 11 min read

Side-by-side diagram: Laravel dispatches to Redis lists consumed by four PHP processes running one job each; BullMQ adds to Redis and one Node.js process runs six jobs at once
Image: Cloudsoft Zone
In this article

Laravel's queue system and BullMQ in a NestJS application both move slow work out of web requests, both commonly sit on Redis, and both retry, delay and rate-limit jobs. The real difference is the execution model: a Laravel worker is a PHP process that runs one job at a time, while a BullMQ worker is a Node.js process that can run many jobs at once on one event loop. For AI workloads, which mostly wait on a model API, that one difference outweighs every other performance number. This comparison covers the architecture, the features that matter in production, and a small benchmark run on the same Redis server, with the hardware, versions and limitations stated.

Tested with: PHP 8.5.7, Laravel 13.20.0 with predis 3.5.1; Node.js 24.18.0 with BullMQ 5.80.9 and ioredis 5.11.1; NestJS 11.1.28 with @nestjs/bullmq 11.0.4 for the code samples; Redis 8.2.2 on localhost. Hardware: Apple M2, 8 cores, 8 GB RAM, macOS. Horizon details refer to Horizon 5.48.0.

This article compares the queue layers only. For the wider choice between the two frameworks (ecosystem, hiring, typing, deployment), read Laravel vs NestJS for SaaS development in 2026.

How each one works

Laravel queues. You dispatch a job class; Laravel serialises it (including Eloquent model identifiers, not the full models) into a JSON payload and pushes it to a connection. Drivers include database, redis, sqs and beanstalkd, plus sync for tests and the newer deferred, background and failover connections. A queue:work process reserves one job, runs it, deletes it and takes the next. Concurrency is the number of worker processes, which Horizon (Redis only) starts, balances across queues and restarts. Timeouts are enforced by the worker with PCNTL signals, which kill the process if a job overruns.

BullMQ. BullMQ is a Node.js library that keeps jobs in Redis (or a Redis-compatible server) and moves them between states (wait, active, delayed, completed, failed) with atomic Lua scripts. A Worker takes a concurrency option: with 50, one process holds up to 50 jobs at once, and while one awaits a network response the event loop runs the others. A lock with periodic renewal marks active jobs; if renewal stops, a stalled-job check returns the job to the queue. NestJS adds dependency injection and decorators through @nestjs/bullmq (@Processor, WorkerHost, @InjectQueue), but the runtime behaviour is BullMQ's.

The same AI job in both

A job that summarises a support ticket with a model API, retried with backoff, unique per ticket and rate-limited, looks like this in each. Both files were syntax-checked (php -l and tsc --noEmit) against the versions above.

PHP
<?php

namespace App\Jobs;

use App\Models\Ticket;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Support\Facades\Http;

#[Tries(5)]
#[Backoff(10, 60, 300)]
#[Timeout(120)]
class SummariseTicket implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public function __construct(public Ticket $ticket) {}

    public function uniqueId(): string
    {
        return (string) $this->ticket->id;
    }

    public function middleware(): array
    {
        return [new RateLimited('llm')]; // RateLimiter::for('llm', ...) in a service provider
    }

    public function handle(): void
    {
        $summary = Http::timeout(90)->post(config('services.llm.url'), ['text' => $this->ticket->body])
            ->throw()->json('summary');
        $this->ticket->update(['summary' => $summary]);
    }
}

// Dispatch: SummariseTicket::dispatch($ticket)->onQueue('ai');
// Run:      php artisan queue:work redis --queue=ai   (or Horizon)
TypeScript
import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { Job, Queue } from 'bullmq';

@Injectable()
export class TicketService {
  constructor(@InjectQueue('ai') private readonly queue: Queue) {}

  summarise(ticketId: number) {
    return this.queue.add('summarise-ticket', { ticketId }, {
      jobId: `ticket-${ticketId}`, // ignored while a job with this ID exists
      attempts: 5,
      backoff: { type: 'exponential', delay: 10_000 },
    });
  }
}

@Processor('ai', { concurrency: 20, limiter: { max: 30, duration: 60_000 } })
export class SummariseTicketProcessor extends WorkerHost {
  async process(job: Job<{ ticketId: number }>, _token?: string, signal?: AbortSignal) {
    const res = await fetch(process.env.LLM_URL!, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ticketId: job.data.ticketId }),
      signal: AbortSignal.any([AbortSignal.timeout(90_000), ...(signal ? [signal] : [])]),
    });
    if (!res.ok) throw new Error(`LLM returned ${res.status}`);
    return res.json();
  }
}
// Module: BullModule.forRoot({ connection }), BullModule.registerQueue({ name: 'ai' })

The shapes are close. The differences are in the details: Laravel's rate limiter is a named limiter you can key by anything (a tenant, a user, a model); BullMQ's limiter applies to the whole queue across all workers. Laravel's timeout kills the worker process; BullMQ has no job timeout, so you put one on each call and pass the processor's AbortSignal so worker.cancelJob() can stop it.

Feature comparison

NeedLaravel 13 queuesBullMQ 5 in NestJS
BackendsDatabase, Redis, SQS, Beanstalkd and moreRedis or Redis-compatible servers
Retries and backofftries, backoff arrays, retryUntil(), maxExceptionsattempts, fixed or exponential backoff with jitter, custom strategies
Duplicate preventionShouldBeUnique, WithoutOverlapping middlewareCustom jobId, deduplication option
Rate limitingNamed limiters keyed by anything, via job middlewareGlobal per queue; manual rateLimit() on 429; per-group limits need BullMQ Pro
ConcurrencyOne job per process; add processesconcurrency per worker plus more processes
Cancel a running jobNo built-in abort; cancel batches; Interruptible reacts to worker signalsworker.cancelJob() aborts the processor's AbortSignal
WorkflowsChains and batchesFlows (parent and child jobs); batches and groups are Pro features
Recurring jobsThe task schedulerJob Schedulers
Failed jobsfailed_jobs table, queue:retry, failed() hookFailed set in Redis, job.retry(), failed events
DashboardHorizon (free, Redis only)Third-party: Bull Board (open source) or Taskforce.sh (commercial)
Progress and eventsJob events; progress is up to youjob.updateProgress() and QueueEvents across processes

BullMQ Pro is a paid edition sold under a per-organisation licence that adds groups (with per-group rate limits and concurrency), batches and observables; check the BullMQ Pro documentation for current terms.

Performance: what we measured

We ran the same three tests against one Redis 8.2.2 server on localhost, three runs each, and report the median. Each job did no real work, or waited a fixed time to stand in for a model API call. Laravel used predis because the phpredis extension was not installed on the test machine; phpredis is a compiled extension and is usually faster, so treat Laravel's no-op numbers as a lower bound. Timings include worker start-up.

TestLaravelBullMQ
Enqueue 10,000 jobs one by one1.23 s1.13 s
Process 10,000 no-op jobs, 1 process (concurrency 1)7.01 s (1,427 jobs/s)1.52 s (6,590 jobs/s)
Process 10,000 no-op jobs, 4 processes2.82 s (3,542 jobs/s)0.83 s (12,062 jobs/s)
Process 10,000 no-op jobs, 1 process, concurrency 50not possible0.44 s (22,794 jobs/s)
400 jobs waiting 250 ms, 4 concurrent26.1 s (4 processes)26.3 s (1 process)
400 jobs waiting 250 ms, high concurrency8.2 s (16 processes, ~44 MB each)2.4 s (concurrency 50, one ~76 MB process)
Bar chart of jobs per second for 400 jobs waiting 250 ms: Laravel 4 processes 15, Laravel 16 processes 49, BullMQ concurrency 4 at 15, BullMQ concurrency 50 at 165
With equal concurrency the two match; BullMQ reaches high concurrency in one small process.

How to read this honestly:

  • Queue overhead is small in both. Even Laravel's slowest result is under a millisecond of overhead per job. A model call takes from about a second to a minute, so for AI jobs neither queue's overhead will show up in your bills or latency.
  • Waiting is where they differ. With the same concurrency (four), both took about 26 seconds, which is simply 400 × 250 ms ÷ 4. To go faster, Laravel needs more processes, at roughly 45 to 50 MB of memory each in our runs; BullMQ raised concurrency inside one process with almost no extra memory.
  • Numbers move. An earlier full run of the same script gave figures up to about 25% different for some rows. Use these to understand the shape of the difference, not as a capacity plan, and measure on your own servers with your own jobs.
Terminal output of our benchmark script listing enqueue time, no-op throughput and I/O-bound throughput for Laravel and BullMQ, with peak memory per process
Raw output of the benchmark run reported in this article.

What matters for AI workloads

  • Concurrency is a cost decision. Running 50 model calls at once only helps if your provider quota allows it. Laravel's per-process model makes over-concurrency expensive in memory, which is not always a bad thing; BullMQ makes high concurrency cheap, so set the limiter carefully.
  • Timeouts and cancellation. Laravel's process-level timeout is a blunt but reliable kill switch. BullMQ gives finer control (abort a single call, cancel a single job) but you must wire signals through yourself.
  • Retry and duplicate safety are equal concerns in both. Neither framework makes a job idempotent for you; you still need stable job IDs, a "result already stored?" check and a unique constraint where results are saved. Both also run a job twice if a worker's timeout and the queue's visibility timeout (Laravel's retry_after, BullMQ's lock duration) are configured inconsistently.
  • Per-tenant fairness. Laravel's keyed rate limiters make "30 AI jobs per minute per customer" a few lines of code. In open-source BullMQ the limiter is per queue, so per-tenant limits need separate queues, your own logic or BullMQ Pro groups.
  • CPU-heavy steps. Parsing PDFs or resizing images blocks the Node.js event loop and stalls every concurrent job in that worker; BullMQ offers sandboxed processors (separate processes) for that. In Laravel each job already has its own process.

Getting these settings right, especially concurrency, rate limits and timeouts against a provider's quota, is where queue projects for AI features usually go wrong. If you are planning that layer for a product, our SaaS development service can design and build it with you on either stack.

Operations

  • Deployments: Laravel workers are long-lived PHP processes that do not see new code until restarted; run php artisan horizon:terminate or queue:restart on deploy. BullMQ workers are restarted with your Node.js process; call worker.close() on SIGTERM so active jobs finish.
  • Redis settings: both store queue state in Redis, so use persistence and a maxmemory-policy of noeviction; an evicted key is a lost job.
  • Monitoring: Horizon gives Laravel throughput, wait times, failed jobs and tags out of the box. For BullMQ, add a dashboard and export metrics or OpenTelemetry traces yourself.
  • No Redis? Laravel's database driver runs on the database you already have, which suits low volumes. BullMQ needs Redis or a compatible server.

Testing and developer experience

Laravel makes queue tests cheap. Queue::fake() and Bus::fake() let you assert that a job was dispatched with the right data without running it, the sync driver runs jobs inline, and the database driver with SQLite lets you run a real worker inside a feature test, including release delays, by moving the test clock. Job behaviour (middleware, uniqueness, failure hooks) can be tested end to end without Redis.

BullMQ has no fake. You either unit-test the processor function directly, passing a hand-built job object, or run integration tests against a real Redis, which is the more honest option because retries, locks, rate limits and events all live in Redis scripts. In practice that means a Redis service in CI and a test database number or key prefix per run. NestJS's testing module helps you swap the model client for a scripted fake, so the queue is real and only the provider is simulated.

On the producing side, both are pleasant to use. Laravel jobs are classes with typed constructor arguments; BullMQ jobs are a name plus JSON data, so give the data a TypeScript type (Job<TicketData>) and validate it in the processor, because nothing checks the payload when an old job written by a previous deploy is picked up by new code.

Which to choose

SituationBetter fit
Your application is LaravelLaravel queues with Horizon; do not add a Node.js service just for jobs
Your application is NestJSBullMQ through @nestjs/bullmq
Many slow, I/O-bound jobs (model calls, webhooks, scraping)BullMQ handles high concurrency with less memory; Laravel works with more processes
Per-customer rate limits without extra licencesLaravel's keyed rate limiters
Need to cancel running jobs from the UIBullMQ's cancelJob and AbortSignal
Small volume, no RedisLaravel's database driver
A free built-in dashboard mattersLaravel Horizon

Common mistakes when comparing

  • Quoting no-op throughput as if it predicted AI job throughput. Your provider's latency and rate limit decide that.
  • Comparing one Laravel process with one BullMQ process without matching concurrency.
  • Forgetting the Redis client. predis and phpredis behave differently; so do ioredis versions.
  • Choosing a queue to justify a rewrite. If the main application is in one framework, its native queue is almost always the right one.

When neither is the right tool

  • For long-running business workflows that wait for people or span days, a workflow engine such as Temporal is easier to reason about than chained jobs.
  • For large overnight AI batches, provider batch APIs are usually cheaper than calling the interactive API from any queue.
  • On a single cloud, managed queues (Amazon SQS, Google Cloud Tasks) remove Redis from your operations; Laravel supports SQS directly.

Cloudsoft Zone builds SaaS products on Laravel and NestJS, including the background job layer for AI features: concurrency, rate limits, retries and monitoring sized to your provider quota. See our SaaS development service.

Frequently asked questions

  • Is BullMQ faster than Laravel queues?

    In our no-op test on one machine, BullMQ processed jobs several times faster per process, partly because Laravel used the pure-PHP predis client. For AI jobs that wait seconds on a model API, queue overhead is negligible in both; what matters is how cheaply each runs many jobs at once.

  • Can a Laravel worker run several jobs at the same time?

    No. Each queue:work process runs one job at a time, so you add processes for concurrency, usually through Horizon. Each process costs memory, about 45 to 50 MB per worker in our test application.

  • Does BullMQ have something like Horizon?

    Not from the BullMQ project itself in the open-source edition. Teams use Bull Board, which is open source, or Taskforce.sh, a commercial service, and export metrics to their own monitoring.

  • Can I rate-limit AI jobs per customer?

    In Laravel, yes: define a named rate limiter keyed by the customer and attach the RateLimited middleware. Open-source BullMQ limits a whole queue; per-customer limits need separate queues, your own logic or BullMQ Pro groups.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Queues (Laravel 13.x) — Laravel
  2. Laravel Horizon (Laravel 13.x) — Laravel
  3. BullMQ documentation: Workers and concurrency — BullMQ
  4. Rate limiting — BullMQ
  5. Cancelling jobs — BullMQ
  6. BullMQ Pro introduction — BullMQ
  7. Queues (@nestjs/bullmq) — NestJS
  8. laravel/framework v13.20.0 — Laravel on GitHub, 2026-07-14

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.