@imqueue/job vs BullMQ: Redis job queues compared
Both are Redis-backed job queues for Node.js. @imqueue/job is small, safe-by-default and scheduling-capable; BullMQ is the feature-rich one. Here's an honest split — plus the thing @imqueue does that BullMQ doesn't.
If you need to run background jobs on Redis in Node.js, BullMQ is the name everyone reaches for. But @imqueue also ships a job queue — @imqueue/job — built on the same Redis-backed core as the rest of the framework. This is an honest comparison of the two, plus one thing @imqueue does that BullMQ doesn't. (BullMQ details reflect its documented behavior at the time of writing.)
The one-line version
@imqueue/job is a deliberately simple, safe-by-default job queue; BullMQ is the feature-rich one. Both run on Redis, both do concurrent workers and delayed jobs, and both can retry a failed job. BullMQ adds a much larger job-lifecycle surface (a declarative retry/backoff policy with dead-lettering, priorities, repeatable/cron jobs, rate limiting, flows, a dashboard). @imqueue/job keeps a tiny footprint and leans on guaranteed delivery being on by default.
What @imqueue/job gives you
@imqueue/job is a Redis-backed job queue with a small, focused feature set:
- Guaranteed delivery by default. Safe delivery is on out of the box, with a per-worker lease TTL (
safeLockTtl) deciding when a holder counts as dead: a job a dying worker was holding is re-queued for another worker rather than vanishing with the process. The lease covers the hand-off, not your handler — a worker killed three seconds into anawaitstill loses that attempt — so at-least-once is the honest guarantee, and handlers should be safe to re-run. - Concurrent workers on one queue — competing consumers with natural load balancing, no separate balancer.
- Delayed / scheduled jobs to millisecond granularity:
push(data, { delay }). - Job expiration (TTL) — a job can live forever or expire after a set time.
- Publisher / worker / both roles, so you can split producers and consumers across processes.
- gzip payload compression, and it's TypeScript-first.
- One dependency (
@imqueue/core), event-driven (no polling), low idle cost.
import JobQueue from '@imqueue/job';
new JobQueue<string>({ name: 'Emails' })
.onPop(job => sendEmail(job))
.start()
.then(q => q
.push('welcome@acme.com')
.push('reminder@acme.com', { delay: 60_000 })); // run in 1 minute
Delayed & scheduled delivery is first-class — everywhere in @imqueue
This is worth stating plainly, because it's easy to assume a "simple" queue can't schedule: delayed delivery is a core capability across the whole framework, not an afterthought.
- In
@imqueue/core, the fundamentalsend(toQueue, message, delay?)takes a delay, implemented with a dedicated delayed-set scored by due time, promoted by Redis keyspace notifications (with a polling fallback) — a proper scheduler, not a hack. - In
@imqueue/rpc, anIMQDelayvalue (withms/s/m/h/dunits) lets you delay any remote call — passed after the call-metadata slot, as delayed and scheduled work without a job system spells out. - In
@imqueue/job, that surfaces aspush(data, { delay }).
So scheduling to the millisecond is built in at every layer.
Retries and backoff
@imqueue/job gives you a retry primitive rather than a retry policy — the timing is entirely yours:
- A handler that returns a delay in milliseconds re-runs the same job after that delay — so you shape the backoff (return a growing delay for exponential backoff) and stop by returning nothing or a negative number. Returning
0isn't a stop: it re-runs immediately, a hot loop. - A handler that throws re-schedules with the job's original delay — so a job pushed without one is dropped rather than retried. Catch your own errors instead of leaning on a throw.
- If a worker dies during the hand-off, safe delivery re-queues the job after
safeLockTtl.
new JobQueue<{ url: string; attempt: number }>({ name: 'Fetch' })
.onPop(async (job) => {
try {
await fetchAndStore(job.url);
} catch (err) {
job.attempt += 1;
if (job.attempt > 5) {
return -1; // negative: stop retrying
}
return 1000 * 2 ** (job.attempt - 1); // 1s, 2s, 4s, 8s, 16s
}
})
.start();
So retries and backoff are expressible — you just write them. What BullMQ adds on top is a declarative policy — a fixed attempts cap plus a named backoff strategy — with the job automatically dead-lettered once attempts run out. In @imqueue/job the counter in the payload above is the only attempt cap there is (re-scheduling re-sends the same envelope, so it carries forward), and parking an exhausted job is your own code.
Where BullMQ goes further
Be fair about this — if you need these, BullMQ is the better fit and @imqueue/job isn't trying to compete:
- A declarative retry policy —
attemptslimit + named backoff strategy + automatic dead-lettering (vs the programmable retries above). - Priorities across queued jobs.
- Repeatable / cron jobs on a recurring schedule.
- Rate limiting of processing.
- Flows — parent/child job dependencies.
- Progress reporting, events, and a dashboard ecosystem (e.g. Bull Board).
@imqueue/job gives you durable, concurrent, schedulable jobs with almost no surface area; BullMQ gives you a full job-orchestration platform.
The thing BullMQ doesn't do: typed RPC
BullMQ is a job queue, full stop. @imqueue is a whole framework on one Redis-backed core, and jobs are just one part of it. The same stack also gives you typed request/response RPC via @imqueue/rpc: call another service like a local function and await a typed result, with the client generated from the service.
So if your system needs both "do this later" (jobs) and "give me this now" (service-to-service calls), @imqueue covers both with one dependency and one mental model. With BullMQ you'd pair it with a separate RPC/HTTP layer for the synchronous half.
Quick comparison
| @imqueue/job | BullMQ | |
|---|---|---|
| Backing store | Redis | Redis |
| Guaranteed delivery | ✅ at-least-once, on by default (re-queue on worker death) | ✅ (stalled-job recovery) |
| Concurrent workers | ✅ competing consumers | ✅ |
| Delayed / scheduled jobs | ✅ millisecond granularity | ✅ |
| Job expiration (TTL) | ✅ | ✅ (retention policies) |
| Retries + backoff | ✅ programmable (return a delay to back off; no attempt cap) | ✅ declarative (attempts + backoff strategy + dead-letter) |
| Priorities | ❌ | ✅ |
| Repeatable / cron | ❌ | ✅ |
| Rate limiting / flows / dashboard | ❌ | ✅ |
| Typed request/response RPC | ✅ (via @imqueue/rpc, same core) | ❌ (jobs only) |
| Dependencies | 1 (@imqueue/core) |
several |
How to choose
- Reach for BullMQ when you need the rich job lifecycle — a declarative retry/backoff policy with dead-lettering, priorities, cron/repeatable jobs, rate limiting, flows, or a ready-made dashboard.
- Reach for
@imqueue/jobwhen you want a small, dependency-light, safe-by-default job queue with delayed/scheduled jobs — especially if you're already using@imqueuefor service communication and want one stack for both jobs and RPC.
To try the @imqueue side, Getting Started covers the framework, and @imqueue/job has the job-queue API.
Read this page as plain markdown — no HTML, no navigation. For pasting into an LLM, or for an agent to fetch.
Building on @imqueue? The open-source packages live on GitHub and the docs at imqueue.org. Shipping inside a closed-source product? See commercial licensing & support.