Background Jobs, Scheduling & Queues
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04. The BullMQ and RabbitMQ examples were run on Node.js 26, 24 and 22; the timer and cron examples were run on Node.js 26 only, the Redis Streams example on 26 and 22, and the idempotency example on 26 and 24, against Redis 8 and RabbitMQ 4 running in containers, with BullMQ 6.3.11, amqplib 2.2.0, node-redis 6.3.0, node-cron 4.6.0 and node-schedule 2.1.1 as resolved on that date. A web request should return in milliseconds; sending the welcome email, resizing the cover image of a book or building the nightly sales report should not hold the response, and should survive a deploy. That work moves into the background: a timer inside the process, a scheduler that fires at wall-clock times, or a queue that stores the work somewhere durable and lets separate worker processes take it. The choice is mostly about what must survive: a timer dies with the process, a queue in a broker outlives it. The running scenario is the Bookshelf application described in Introduction & Architecture; the examples are the jobs it would need (a welcome email, an order, a nightly report). Messaging theory (models, delivery semantics, Kafka and RabbitMQ internals) lives in the Messaging Reference; this page shows the Node.js side.
Choosing a mechanism
| Mechanism | Survives a restart | Runs on several instances | Typical use |
|---|---|---|---|
|
No |
Once per instance |
Cache refresh, heartbeats, polling; anything that is fine to lose |
|
No (the schedule is rebuilt at start, missed runs are not replayed) |
Once per instance, so a job fires N times behind N replicas |
Wall-clock tasks on a single instance, or a task that is cheap and idempotent |
BullMQ (Redis) |
Yes, jobs live in Redis |
Exactly one worker takes each job; schedulers are deduplicated by id |
Application jobs with retries, delays, priorities and recurring schedules |
RabbitMQ with |
Yes for durable queues and persistent messages |
Competing consumers on one queue |
Work shared between services and languages, routing, dead-lettering |
Redis Streams |
Yes, as far as Redis persistence is configured |
Consumer groups share the entries |
Event log with replay and several independent consumer groups |
Redis Pub/Sub |
No, there is no storage at all |
Every subscriber gets every message |
Live notifications that may be lost |
In-process timers
setTimeout(), setInterval() and setImmediate() are scheduled by the Node.js timers machinery inside the event loop (see
Timers and The Event Loop). They are the cheapest scheduler there is, and they
have four properties that matter for background work. They are not durable: when the process exits the schedule is gone. They are not precise:
the callback runs no sooner than the delay, and later if the loop is busy. They keep the process alive unless the timer is unref()-ed. And a
setInterval() whose callback takes longer than the interval does not wait for the previous run: callbacks of async functions overlap. The safe shape
for periodic async work is a self-scheduling loop built on node:timers/promises, where the next wait starts when the previous run ends, and an
AbortSignal stops it cleanly during shutdown (see Async Context & Cancellation).
import { setTimeout as sleep } from 'node:timers/promises';
const controller = new AbortController();
let runs = 0;
// Self-scheduling loop: the next run starts only after the previous one ends,
// so a slow run can never overlap with the next tick.
async function loop(everyMs, signal) {
while (!signal.aborted) {
const started = Date.now();
runs += 1;
console.log(`run ${runs} start`);
await sleep(120); // pretend this is the job
console.log(`run ${runs} done in ${Date.now() - started} ms`);
try {
await sleep(everyMs, undefined, { signal });
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
}
}
setTimeout(() => controller.abort(), 700);
await loop(200, controller.signal);
console.log('stopped after', runs, 'runs');
run 1 start
run 1 done in 122 ms
run 2 start
run 2 done in 122 ms
run 3 start
run 3 done in 122 ms
stopped after 3 runs
A timer that only exists to do housekeeping should call timeout.unref() so that it never keeps an otherwise finished process alive, and every
long-lived timer should be cleared during graceful shutdown (see
Process, Signals & Exit Codes). Work that is CPU heavy does not belong in a timer
callback at all, because it blocks the loop for everyone (see Don’t Block the Event Loop); move it
to a worker thread or a separate worker process (see Worker Threads and
Child Processes).
Cron-style schedulers
When the requirement is "every night at 03:00" and not "every 24 hours after start", a cron expression is the right vocabulary. Two packages are
common. node-cron (Node.js 20 or later, ESM and CommonJS) takes a five or six field expression, an optional timezone and a noOverlap option that skips a tick while the
previous run is still going. node-schedule accepts cron strings, Date objects and recurrence rules, and returns a Job with cancel(). Both
keep the schedule in memory, so they inherit the limits of timers: nothing is stored, a missed run (the process was down at 03:00) is not replayed,
and every replica fires its own copy. Wall-clock schedules also meet daylight-saving shifts, so pass an explicit timezone ('UTC' avoids the problem
for fixed intervals) and make the task idempotent (see Idempotency).
import cron from 'node-cron';
import schedule from 'node-schedule';
let n = 0;
const task = cron.schedule('* * * * * *', (now) => {
n += 1;
console.log('node-cron tick', n);
}, { name: 'pgjobs-demo', noOverlap: true });
const job = schedule.scheduleJob('*/1 * * * * *', () => console.log('node-schedule tick'));
setTimeout(async () => {
await task.stop();
job.cancel();
console.log('stopped');
process.exit(0);
}, 2500);
node-cron tick 1
node-schedule tick
node-cron tick 2
node-schedule tick
stopped
Source: node-cron: README, node-schedule: README
Use an in-process scheduler when the job is cheap, idempotent and can run once per instance, or when exactly one instance exists. As soon as the application scales out, move the schedule into the queue (BullMQ job schedulers below), into the platform (a Kubernetes CronJob, see the Kubernetes section), or behind a lock so that only one replica runs it.
BullMQ
BullMQ is the standard Redis-backed job queue for Node.js (it succeeds the older Bull package, which its documentation calls the legacy version: maintained for bugs, not for new major features). A queue is the producer
side: it adds jobs, which are stored in Redis with their data and options. A worker is the consumer side: a process that takes jobs and runs a
processor function. Several workers on the same queue compete for jobs, and a job is processed by one of them at a time. A QueueEvents instance
listens to the queue’s event stream (completed, failed, progress) from anywhere. Jobs are stored as data, so the producer (the web process) and
the worker (a separate process or container) only share the queue name and the Redis instance; this is what lets the API return immediately and the
worker be scaled, restarted and deployed independently.
Since BullMQ 6 the Redis client is an optional peer dependency: the connection options are handed to ioredis by default, so npm install bullmq ioredis
is needed (observed here: without it the constructors fail with an error that says to install it), and BullMQ’s connection guide describes adapters for
node-redis and Bun’s Redis client as alternatives. A Worker needs blocking Redis commands, so it opens its own connections.
import { Queue, Worker, QueueEvents, UnrecoverableError } from 'bullmq';
const connection = { host: process.env.REDIS_HOST ?? '127.0.0.1', port: 6379 };
const name = 'pgjobs-bull-email';
const queue = new Queue(name, {
connection,
defaultJobOptions: {
attempts: 4,
backoff: { type: 'exponential', delay: 100 },
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 24 * 3600 },
},
});
const events = new QueueEvents(name, { connection });
const seen = new Map();
const worker = new Worker(name, async (job) => {
const n = (seen.get(job.id) ?? 0) + 1;
seen.set(job.id, n);
console.log(`job ${job.id} ${job.name} attempt ${job.attemptsMade + 1}`);
if (job.data.invalid) throw new UnrecoverableError('bad payload');
if (job.data.flaky && n < 3) throw new Error('smtp timeout');
return { sent: job.data.to };
}, { connection, concurrency: 2 });
worker.on('failed', (job, err) => console.log(`failed ${job?.id}: ${err.message}`));
worker.on('error', (err) => console.error('worker error', err));
// Idempotent producer: the same jobId is ignored while the job still exists.
const a = await queue.add('welcome', { to: 'ada@example.com', flaky: true }, { jobId: 'welcome-42' });
await queue.add('welcome', { to: 'ada@example.com', flaky: true }, { jobId: 'welcome-42' });
const bad = await queue.add('welcome', { invalid: true }, { jobId: 'bad-1' });
console.log('result', await a.waitUntilFinished(events, 10_000));
await bad.waitUntilFinished(events, 10_000).catch((e) => console.log('bad ->', e.message));
console.log('counts', await queue.getJobCounts('waiting', 'active', 'completed', 'failed'));
await worker.close(); // waits for active jobs
await events.close();
await queue.obliterate({ force: true });
await queue.close();
job welcome-42 welcome attempt 1
failed welcome-42: smtp timeout
job bad-1 welcome attempt 1
failed bad-1: bad payload
job welcome-42 welcome attempt 2
failed welcome-42: smtp timeout
job welcome-42 welcome attempt 3
result { sent: 'ada@example.com' }
bad -> bad payload
counts { waiting: 0, active: 0, completed: 1, failed: 1 }
The options that decide how a queue behaves in production:
| Option | What it does |
|---|---|
|
A job that throws is retried up to |
|
Throw it from the processor to fail the job at once and skip the remaining attempts (bad input will never succeed). |
|
How many jobs one worker runs in parallel. They share one event loop, so this fits I/O-bound jobs; CPU-bound jobs need sandboxed processors, worker threads or more worker processes. |
|
Without them every finished job stays in Redis. Keep a bounded history (by age and count) and keep failures longer for diagnosis. |
|
Delayed jobs and priorities are stored in Redis, not in a timer, so they survive restarts. |
|
A caller-chosen id. Adding a job whose id already exists is ignored, which makes the producer idempotent while the first job is retained. |
Stalled jobs |
If a worker dies or blocks the loop so its lock expires, the job is picked up again. Processing is therefore at-least-once, not exactly-once. |
Recurring jobs with job schedulers
BullMQ replaces in-process cron with a job scheduler: a named definition (every milliseconds, or a cron pattern with a tz) stored in Redis that
produces a job each time it fires. upsertJobScheduler() creates or updates it by id, so calling it on every deploy does not duplicate it, and it does
not matter how many replicas execute the call. Only one delayed job per scheduler exists at a time, and the next one is produced when the current one
starts, so a saturated queue produces jobs less often than the interval. Older code that used repeat options on queue.add() ("repeatable jobs") should
move to this API.
import { Queue, Worker } from 'bullmq';
const connection = { host: '127.0.0.1', port: 6379 };
const name = 'pgjobs-bull-report';
const queue = new Queue(name, { connection });
// One scheduler id = one recurring definition, safe to run on every deploy.
await queue.upsertJobScheduler(
'nightly-report',
{ every: 300 }, // or { pattern: '0 3 * * *', tz: 'UTC' }
{ name: 'report', data: { kind: 'sales' }, opts: { removeOnComplete: true } },
);
let count = 0;
const worker = new Worker(name, async (job) => {
count += 1;
console.log('report job', job.name, job.data.kind, count);
}, { connection });
await new Promise((r) => setTimeout(r, 1500));
console.log('schedulers', (await queue.getJobSchedulers()).map((s) => s.key));
await queue.removeJobScheduler('nightly-report');
await worker.close();
await queue.obliterate({ force: true });
await queue.close();
report job report sales 1
report job report sales 2
report job report sales 3
report job report sales 4
report job report sales 5
schedulers [ 'nightly-report' ]
report job report sales 6
Source: BullMQ: Job Schedulers, BullMQ: Repeatable
Workers belong in their own process. A worker should handle SIGTERM by calling await worker.close(), which stops taking jobs and waits for the active
ones; the grace period of the orchestrator must be longer than the longest job. BullMQ’s going-to-production guide covers the Redis settings that matter
(above all maxmemory-policy set to noeviction, which BullMQ documents as the only setting that guarantees correct queue behaviour).
RabbitMQ with amqplib
When the producer and the consumer are different services, possibly written in different languages, or when routing, per-message acknowledgements and
dead-lettering matter more than Redis-style job features, RabbitMQ is the broker. In Node.js the client is amqplib, whose promise API
(require('amqplib') or import amqp from 'amqplib'; the callback API is amqplib/callback_api) mirrors the AMQP 0-9-1 model: a connection holds
channels, and a channel declares queues and publishes or consumes. The model itself (exchanges, bindings, queue types, quorum queues) is explained in
RabbitMQ Architecture and the AMQP Model,
Publishing to RabbitMQ and Consuming from RabbitMQ;
the five habits that make a Node.js job pipeline reliable are these.
| Habit | Why |
|---|---|
Declare the queue |
A transient queue is deleted when the broker restarts. Durable queues keep their definition. |
Publish with |
Delivery mode 2 asks the broker to write the message to disk; without it a durable queue still loses its transient messages on restart. |
Use a confirm channel ( |
Without confirms, |
Set |
It limits how many unacknowledged messages the broker pushes to a consumer. Without it a fast broker floods one slow worker, and a crash requeues a huge batch. |
Acknowledge after processing ( |
An ack before the work is done turns a crash into silent message loss. An un-acked message is requeued when the channel closes. |
import amqp from 'amqplib';
const url = process.env.AMQP_URL ?? 'amqp://127.0.0.1:5672'; // credentials come from the environment/URL
const queue = 'pgjobs-orders';
const dlq = 'pgjobs-orders.dead';
const conn = await amqp.connect(url);
conn.on('error', (err) => console.error('connection error', err.message));
// Topology: durable work queue that dead-letters rejected messages.
const setup = await conn.createChannel();
await setup.assertQueue(dlq, { durable: true });
await setup.assertQueue(queue, {
durable: true,
arguments: { 'x-dead-letter-exchange': '', 'x-dead-letter-routing-key': dlq },
});
await setup.close();
// Producer: a confirm channel resolves when the broker has taken responsibility.
const pub = await conn.createConfirmChannel();
for (const id of ['o-1', 'o-2', 'o-3', 'poison']) {
pub.sendToQueue(queue, Buffer.from(JSON.stringify({ id })), {
persistent: true, // deliveryMode 2: written to disk on durable queues
contentType: 'application/json',
messageId: id,
});
}
await pub.waitForConfirms();
console.log('all publishes confirmed');
// Consumer: prefetch bounds unacked messages; ack only after the work is done.
const ch = await conn.createChannel();
await ch.prefetch(2);
let done = 0;
const { consumerTag } = await ch.consume(queue, async (msg) => {
if (msg === null) return; // consumer cancelled by the broker
const { id } = JSON.parse(msg.content.toString());
try {
if (id === 'poison') throw new Error('cannot process');
await new Promise((r) => setTimeout(r, 50)); // the real work
ch.ack(msg); // after processing, never before
console.log('acked', id);
} catch (err) {
ch.nack(msg, false, false); // requeue=false -> dead-letter queue
console.log('rejected', id, '->', dlq);
}
if (++done === 4) {
await ch.cancel(consumerTag);
console.log('dead letters:', (await ch.checkQueue(dlq)).messageCount);
await ch.deleteQueue(queue);
await ch.deleteQueue(dlq);
await ch.close();
await pub.close();
await conn.close();
}
}, { noAck: false });
all publishes confirmed
acked o-1
acked o-2
rejected poison -> pgjobs-orders.dead
acked o-3
dead letters: 1
Source: amqplib: Channel API reference, RabbitMQ: Consumer Acknowledgements and Publisher Confirms, RabbitMQ: Consumer Prefetch, RabbitMQ: Reliability Guide
Points the example deliberately shows. nack(msg, false, false) rejects one message without requeueing it, so the broker dead-letters it; requeueing a poison
message with nack(msg, false, true) makes it loop forever (see
RabbitMQ Reliability, Dead-Lettering and TTL). A consumer callback receives
null when the broker cancels the consumer, which must be handled. Publisher confirms and consumer acks together give at-least-once delivery: a
message can be delivered twice (the consumer crashed after the work but before the ack), so the handler has to be idempotent (see Idempotency
and Delivery Guarantees). Credentials are never written in the source: the URL or its parts come from the
environment, and amqps:// is used outside a local machine.
Connections drop in real life (broker restarts, network blips). A bare connection stays closed once it fails, so the process has to reconnect and
recreate its topology and consumers. amqplib added opt-in automatic recovery in 1.1.0 (the recovery connect option, with a setup(model) function that
runs after every successful connect and recreates queues and consumers, and backoff, jitter and maxRetries settings); version 2.1.0 added waitForConnect,
and 2.2.0 added calculateDelay and initialMaxRetries. In the same line, 2.0.0 changed heartbeat: 0 to disable heartbeats instead of
meaning "use the server’s value". Check the project’s changelog before upgrading across these.
Redis Streams versus Pub/Sub
Redis offers two messaging features that look similar from a client and behave very differently. Pub/Sub is a broadcast: PUBLISH sends a message to
the subscribers connected at that moment and returns how many there were; nothing is stored, a subscriber that is disconnected or slow misses the
message, and there is no acknowledgement (at-most-once). That is correct for live presence or cache-invalidation hints, and wrong for work that must
happen. A Stream is an append-only log: XADD stores an entry, XREADGROUP lets the members of a consumer group share the entries, XACK marks one
as done, and the group’s pending list remembers what was delivered but not acknowledged, so a crashed consumer’s entries can be claimed by another with
XAUTOCLAIM. That is at-least-once with replay, and it is what to reach for when Redis is the only infrastructure and a full job queue is more than needed.
import { createClient } from 'redis';
const stream = 'pgjobs:events';
const group = 'pgjobs-workers';
const url = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379';
const pub = createClient({ url });
const sub = pub.duplicate(); // Pub/Sub needs its own connection
const worker = pub.duplicate();
for (const c of [pub, sub, worker]) c.on('error', (e) => console.error(e.message));
await Promise.all([pub.connect(), sub.connect(), worker.connect()]);
await pub.del(stream);
// 1. Pub/Sub: fire and forget. Nobody subscribed yet -> zero receivers, message gone.
console.log('receivers before subscribe:', await pub.publish('pgjobs:chan', 'lost'));
await sub.subscribe('pgjobs:chan', (m) => console.log('pubsub got', m));
console.log('receivers after subscribe:', await pub.publish('pgjobs:chan', 'seen'));
// 2. Streams: appended entries stay until trimmed; a consumer group tracks delivery.
await pub.xAdd(stream, '*', { type: 'order', id: 'o-1' });
await pub.xAdd(stream, '*', { type: 'order', id: 'o-2' });
await pub.xGroupCreate(stream, group, '0', { MKSTREAM: true }).catch((e) => {
if (!String(e.message).startsWith('BUSYGROUP')) throw e;
});
const read = await worker.xReadGroup(group, 'worker-1', { key: stream, id: '>' }, { COUNT: 10, BLOCK: 1000 });
for (const { messages } of read) {
for (const { id, message } of messages) {
console.log('processing', id, message.id);
await worker.xAck(stream, group, id); // ack only after the work succeeded
}
}
const pending = await worker.xPending(stream, group);
console.log('pending after ack:', pending.pending);
await sub.unsubscribe('pgjobs:chan');
await pub.del(stream);
await Promise.all([pub.quit(), sub.quit(), worker.quit()]);
receivers before subscribe: 0
pubsub got seen
receivers after subscribe: 1
processing <ms>-0 o-1
processing <ms>-1 o-2
pending after ack: 0
Streams grow until trimmed (XADD with MAXLEN or MINID, or XTRIM), and a stream consumer must implement its own retry, backoff and dead-letter
policy, which BullMQ and RabbitMQ give out of the box. The Redis commands, persistence modes and data-modelling advice are covered in
Streams and Pub/Sub and Redis clients; for a log that must be
replayed by many independent consumers at high volume, see Kafka in Kafka vs. RabbitMQ.
Idempotency
Every mechanism above that survives failure (BullMQ stalled jobs and retries, RabbitMQ redelivery, Streams pending entries, a cron job on two replicas) can run the same work twice, so the handler must tolerate it: running it twice has the effect of running it once. Exactly-once delivery is not something to assume; the practical form is at-least-once delivery plus an idempotent handler (see Delivery Guarantees and Messaging Patterns in Practice). The techniques, from strongest to cheapest:
-
Make the operation naturally idempotent:
UPDATE … SET status = 'shipped'instead of a counter increment; an upsert keyed by the business id. -
Give every job a deterministic id (BullMQ
jobId, an AMQPmessageId, a stream entry field) derived from the business event, for example the order id, not a random UUID per attempt. -
Record processed ids in the same transaction as the effect (a unique constraint on the processed-messages table), which is the only fully safe form when the effect is a database write (see Databases from Node.js).
-
For effects outside your database (an email, a payment), pass the provider’s idempotency key if it has one, and otherwise claim the id before the effect with a short lease and mark it done afterwards.
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379' });
await redis.connect();
// Run `work` at most once per message id, even if the broker redelivers it.
async function once(id, work) {
const key = `pgjobs:done:${id}`;
// NX = only if absent; the short TTL is a lease so a crashed worker can be retried.
const claimed = await redis.set(key, 'in-progress', { NX: true, EX: 30 });
if (claimed === null) return 'duplicate';
try {
await work();
await redis.set(key, 'done', { EX: 7 * 24 * 3600 }); // remember long enough to cover redelivery
return 'processed';
} catch (err) {
await redis.del(key); // let the retry run again
throw err;
}
}
let charges = 0;
const charge = async () => { charges += 1; };
console.log(await once('order-17', charge));
console.log(await once('order-17', charge)); // redelivery
console.log('charges:', charges);
await redis.del('pgjobs:done:order-17');
await redis.quit();
processed
duplicate
charges: 1
This pattern narrows the window but is not a transaction: if the process dies between the effect and the 'done' write, the lease expires and the work
runs again. That is why the effect itself should be idempotent whenever it can be.
What changed recently
| Area | Change |
|---|---|
Node.js 22, 24, 26 |
Nothing on this page depends on a Node.js version: BullMQ, amqplib and node-redis ran unchanged on 22.23.3, 24.21.0 and 26.10.0, and |
BullMQ 6 |
The Redis client is an optional peer dependency, so |
amqplib 1.x and 2.x |
Minimum Node.js 18 (1.0.0); bundled TypeScript types (1.2.0); opt-in connection recovery (1.1.0, extended in 2.1.0 and 2.2.0); |
node-redis 6 |
The promise-based client used above; |
Ecosystem |
Bull is superseded by BullMQ, and Fastify 4 and Node.js 20 are end of life (see Release Lines & LTS). |
Source: amqplib: CHANGELOG, BullMQ: Connections
Where the books are outdated
The two reference books both build a queue-driven example, and a few of their patterns should not be copied. Node.js Projects (Jonathan Wexler) uses Redis
Pub/Sub as if it were a durable job queue; as shown above, Pub/Sub keeps nothing, so use a Stream with a consumer group, BullMQ or RabbitMQ. Its RabbitMQ
example acknowledges a message before the downstream work has finished, which loses the message if the process dies in between; acknowledge after
processing. Its scheduling example also passes a wrong key to node-schedule; the schedule is given as a cron string, a Date or a recurrence rule to
scheduleJob() (see the README). Its queue examples are in-memory or use the legacy Bull lineage, whereas the maintained choice today is BullMQ. Treat any
queue example that omits publisher confirms, consumer prefetch and idempotent handlers as incomplete, because those are the parts that make a queue safe.
Efficient Node.js (Samer Buna) is the better source for the timers and event-loop background of this page than for queues.
References
-
Node.js API: Timers, https://nodejs.org/docs/latest/api/timers.html
-
Node.js Learn: Discover JavaScript Timers, https://nodejs.org/learn/asynchronous-work/discover-javascript-timers
-
node-cron, https://github.com/node-cron/node-cron
-
node-schedule, https://github.com/node-schedule/node-schedule
-
BullMQ: documentation, https://docs.bullmq.io/
-
BullMQ: Connections, https://docs.bullmq.io/guide/connections
-
BullMQ: Queues, https://docs.bullmq.io/guide/queues
-
BullMQ: Workers, https://docs.bullmq.io/guide/workers
-
BullMQ: Retrying failing jobs, https://docs.bullmq.io/guide/retrying-failing-jobs
-
BullMQ: Job Ids, https://docs.bullmq.io/guide/jobs/job-ids
-
BullMQ: Deduplication, https://docs.bullmq.io/guide/jobs/deduplication
-
BullMQ: Auto-removal of jobs, https://docs.bullmq.io/guide/queues/auto-removal-of-jobs
-
BullMQ: Job Schedulers, https://docs.bullmq.io/guide/job-schedulers
-
BullMQ: Graceful shutdown, https://docs.bullmq.io/guide/workers/graceful-shutdown
-
BullMQ: Going to production, https://docs.bullmq.io/guide/going-to-production
-
amqplib: documentation, https://amqp-node.github.io/amqplib/
-
amqplib: Channel API reference, https://amqp-node.github.io/amqplib/channel_api.html
-
amqplib: CHANGELOG, https://github.com/amqp-node/amqplib/blob/main/CHANGELOG.md
-
RabbitMQ: Consumer Acknowledgements and Publisher Confirms, https://www.rabbitmq.com/docs/confirms
-
RabbitMQ: Consumer Prefetch, https://www.rabbitmq.com/docs/consumer-prefetch
-
RabbitMQ: Reliability Guide, https://www.rabbitmq.com/docs/reliability
-
RabbitMQ: Queues, https://www.rabbitmq.com/docs/queues
-
Redis: Pub/Sub, https://redis.io/docs/latest/develop/pubsub/
-
Redis: Streams, https://redis.io/docs/latest/develop/data-types/streams/
-
Redis: SET, https://redis.io/docs/latest/commands/set/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/