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

Table 1. Where the work lives and what survives
Mechanism Survives a restart Runs on several instances Typical use

setTimeout / setInterval in the process

No

Once per instance

Cache refresh, heartbeats, polling; anything that is fine to lose

node-cron, node-schedule in the process

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 amqplib

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).

A periodic job that never overlaps itself and stops on abort
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');
Output
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).

Cron expressions with node-cron and node-schedule
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);
Output
node-cron tick 1
node-schedule tick
node-cron tick 2
node-schedule tick
stopped

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.

Retries with exponential backoff, an unrecoverable failure and an idempotent producer
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();
Output (the order of lines from the two jobs can differ between runs)
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:

Table 2. BullMQ options worth knowing
Option What it does

attempts, backoff

A job that throws is retried up to attempts times in total. backoff is { type: 'fixed' | 'exponential', delay } in milliseconds, or a custom strategy on the worker; exponential waits 2 ^ (attempts - 1) * delay milliseconds. The optional jitter (a fraction between 0 and 1) randomises the delay so that retries from a large fleet do not arrive together.

UnrecoverableError

Throw it from the processor to fail the job at once and skip the remaining attempts (bad input will never succeed).

concurrency (worker)

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.

removeOnComplete, removeOnFail

Without them every finished job stays in Redis. Keep a bounded history (by age and count) and keep failures longer for diagnosis.

delay, priority

Delayed jobs and priorities are stored in Redis, not in a timer, so they survive restarts.

jobId

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.

A recurring report that is deployed with the application
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();
Output (1.5 seconds at a 300 ms interval; the count can differ by one)
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

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.

Table 3. The reliability checklist for amqplib
Habit Why

Declare the queue durable: true

A transient queue is deleted when the broker restarts. Durable queues keep their definition.

Publish with persistent: true

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 (createConfirmChannel()) and waitForConfirms()

Without confirms, sendToQueue() only means "handed to the socket". A confirm means the broker took responsibility (for a persistent message on a durable queue, after it was written).

Set channel.prefetch(n)

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 (noAck: false, then ack())

An ack before the work is done turns a crash into silent message loss. An un-acked message is requeued when the channel closes.

Durable queue, confirmed persistent publishes, prefetch, ack after work and a dead-letter queue
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 });
Output
all publishes confirmed
acked o-1
acked o-2
rejected poison -> pgjobs-orders.dead
acked o-3
dead letters: 1

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.

Pub/Sub loses messages, a Stream with a consumer group does not
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()]);
Output (entry ids are timestamps and shortened here)
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 AMQP messageId, 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.

Claiming a message id with a lease so redelivery does not repeat the effect
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();
Output
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

Table 4. Version notes for this page
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 node-cron 4 requires Node.js 20 or later (its engines field), so Node.js 22 is the oldest line that is still supported and works with all of them.

BullMQ 6

The Redis client is an optional peer dependency, so ioredis has to be installed explicitly (observed on 6.3.11); job schedulers (upsertJobScheduler()) are the documented way to produce recurring jobs.

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); heartbeat: 0 disables heartbeats (2.0.0). It talks to RabbitMQ 4 (its 1.0.4 build moved to RabbitMQ 4.2).

node-redis 6

The promise-based client used above; duplicate() gives the extra connections Pub/Sub and blocking reads require.

Ecosystem

Bull is superseded by BullMQ, and Fastify 4 and Node.js 20 are end of life (see Release Lines & LTS).

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