Profiles·Public

bull

semver>=3.0.0 <5.0.0postconditions53functions29last verified2026-06-24coverage score91%

Postconditions: what we check

  • Queue.process · missing-error-handler
    error
    WhenJob processor doesn't handle errors via try-catch or done(error)
    ThrowsThrows unhandled exception that Bull captures and marks job as failed
    Required handlingCaller MUST handle errors in job processor using either: 1. try-catch in async processors: try { await work(); } catch (err) { throw err; } 2. done(error) in callback processors: done(err) Without error handling, exceptions are caught by Bull but may not be logged, and failed jobs accumulate silently if no 'failed' event listener exists.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[1]
  • Queue.process · missing-failed-listener
    error
    WhenQueue instance created without 'failed' event listener
    ThrowsEmits 'failed' event when job fails
    Required handlingCaller MUST attach 'failed' event listener to queue instance. Without this listener, failed jobs are silently lost with no visibility. CRITICAL: This is production bug #2 (60-70% of codebases). Always add: queue.on('failed', (job, err) => { logger.error('Job failed:', err); })
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[2]
  • Queue.process · missing-stalled-listener
    error
    WhenQueue instance created without 'stalled' event listener
    ThrowsEmits 'stalled' event when job stalls (CPU-intensive code blocks event loop)
    Required handlingCaller MUST attach 'stalled' event listener to queue instance. Stalled jobs are restarted by another worker, resulting in DUPLICATE PROCESSING. CRITICAL: This is production bug #1 (80-90% of codebases don't detect this). Impact: Duplicate emails, duplicate payments, data corruption. Always add: queue.on('stalled', (job) => { logger.error('Job stalled:', job.id); })
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[3]
  • Queue.process · missing-error-listener
    warning
    WhenQueue instance created without 'error' event listener
    ThrowsEmits 'error' event for Redis connection errors, queue errors
    Required handlingCaller SHOULD attach 'error' event listener to queue instance. Without this listener, Redis connection errors and queue errors go unnoticed. Always add: queue.on('error', (error) => { logger.error('Queue error:', error); })
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[1]
  • new Queue · missing-queue-listeners
    error
    WhenQueue instantiated without required event listeners
    ThrowsQueue emits events but no listeners attached
    Required handlingAfter creating Queue instance, MUST attach event listeners: 1. queue.on('failed', ...) - REQUIRED 2. queue.on('stalled', ...) - CRITICAL 3. queue.on('error', ...) - RECOMMENDED See Queue.process postconditions for details.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[1]
  • Queue.add · add-redis-connection-error
    error
    WhenQueue.add() called when Redis connection is unavailable
    Required handlingCaller MUST wrap Queue.add() in try-catch and handle the Error. Without error handling, jobs are lost silently with no retry mechanism. Always log the error and implement a fallback (retry queue, dead letter, alert).
    costhighin prodimmediate exceptionusers seedegraded performancevisibilitysilent
    Sources[1][4]
  • Queue.add · add-duplicate-job-id-silent-drop
    warning
    WhenQueue.add() called with a jobId that already exists in the queue
    Required handlingCaller SHOULD verify job creation by checking the returned Job object or querying job status after add(). For deduplication flows, document the intent explicitly. Do not assume add() always creates a new job.
    costmediumin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[1]
  • Queue.addBulk · addbulk-redis-connection-error
    error
    WhenQueue.addBulk() called when Redis connection is unavailable
    Required handlingCaller MUST wrap Queue.addBulk() in try-catch and handle the Error. On failure, the entire batch is lost — implement retry logic or store the batch for resubmission. Log the error with the batch contents.
    costhighin prodimmediate exceptionusers seedegraded performancevisibilitysilent
    Sources[1]
  • Queue.addBulk · addbulk-repeat-option-unsupported
    warning
    WhenQueue.addBulk() called with jobs that include a repeat option
    Required handlingDo not use the repeat option with addBulk(). Add repeating jobs individually via Queue.add() to ensure the repeat scheduler is engaged.
    costmediumin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[5]
  • Queue.close · close-not-called-on-shutdown
    error
    WhenQueue instance created without registering a SIGTERM/shutdown handler that calls Queue.close()
    Required handlingCaller MUST register process shutdown handlers that call queue.close(). Add: process.on('SIGTERM', () => queue.close()) and: process.on('SIGINT', () => queue.close()) Without this, the process hangs on exit and leaks Redis connections.
    costmediumin proddelayed failureusers seeservice unavailablevisibilityvisible
    Sources[1]
  • Queue.close · close-called-mid-job
    warning
    WhenQueue.close() called while active jobs are still processing
    Required handlingSet a timeout for graceful shutdown. If shutdown takes too long, force exit with process.exit(). Document the expected shutdown behavior.
    costmediumin proddelayed failureusers seeservice unavailablevisibilityvisible
    Sources[1]
  • Queue.obliterate · obliterate-active-jobs-error
    error
    Whenobliterate() called without opts.force=true while active jobs are processing
    ThrowsError('Cannot obliterate queue that has active jobs, use force option to force obliteration')
    Required handlingCaller MUST wrap obliterate() in try-catch. Drain the queue first (pause, wait for active jobs), or use obliterate({ force: true }) only when data loss is acceptable (test environments). Never call obliterate() in production request handlers without explicit environment guards.
    costhighin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[6]
  • Queue.obliterate · obliterate-irreversible
    error
    Whenobliterate() completes successfully in a production environment without environment guards
    ThrowsNo error — but all queue data is permanently deleted from Redis with no undo
    Required handlingCallers MUST add explicit environment guards: check NODE_ENV !== 'production' before calling obliterate(). Add admin confirmation for any production use. There is no undo — once obliterated, all job history is gone.
    costhighin prodsilent failureusers seeservice unavailablevisibilitysilent
    Sources[7]
  • Queue.clean · clean-missing-grace-period
    error
    Whenclean() called with grace argument as undefined or null
    ThrowsError('You must define a grace period.')
    Required handlingAlways provide the grace period as first argument (milliseconds). Example: queue.clean(7 * 24 * 3600 * 1000, 'completed'). Wrap in try-catch in maintenance scripts and cron jobs.
    costlowin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[6]
  • Queue.clean · clean-invalid-type
    error
    Whenclean() type argument is not one of: 'completed', 'wait', 'active', 'paused', 'delayed', 'failed'
    ThrowsError('Cannot clean unknown queue type <type>')
    Required handlingUse 'wait' (not 'waiting') for waiting jobs. Valid type strings are: 'completed', 'wait', 'active', 'paused', 'delayed', 'failed'. The monitoring API returns 'waiting' but clean() requires 'wait'. Wrap in try-catch to handle invalid type errors gracefully.
    costlowin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[6]
  • Job.retry · retry-job-not-exist
    error
    WhenJob.retry() called on a job that no longer exists in Redis
    Required handlingCaller MUST wrap Job.retry() in try-catch. Verify job existence before retrying, or handle the not-exist error gracefully in admin retry flows.
    costlowin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[1]
  • Job.retry · retry-job-not-failed
    error
    WhenJob.retry() called on a job that is not in the failed state
    Required handlingCaller MUST wrap Job.retry() in try-catch and handle the not-failed error. Check job.getState() before retrying, or catch and ignore this specific error in bulk retry flows.
    costlowin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[1]
  • Queue.empty · empty-concurrent-add-race
    warning
    WhenQueue.empty() called while another process is concurrently adding jobs to the queue
    Required handlingCaller SHOULD pause the queue before emptying to prevent concurrent adds. Add: await queue.pause(); await queue.empty(); await queue.resume(). In single-process environments without concurrent writers, this is safe. In clustered environments, coordinate empty() with a distributed lock.
    costmediumin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[6][8]
  • Queue.empty · empty-zombie-jobs-on-crash
    error
    WhenQueue.empty() called without wrapping in try-catch, and Redis connection drops mid-execution
    Required handlingCaller MUST wrap Queue.empty() in try-catch. On error, scan Redis for orphaned job keys and clean them up. Consider using Queue.obliterate({ force: true }) for a more complete cleanup in test environments, as obliterate uses Lua scripts that are more atomic than empty().
    costmediumin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[6]
  • Queue.removeRepeatable · remove-repeatable-silent-mismatch
    error
    WhenremoveRepeatable() called with RepeatOpts that do not exactly match the scheduled job's options
    Required handlingAfter calling removeRepeatable(), verify removal by calling queue.getRepeatableJobs() and checking that the job no longer appears. Store the exact RepeatOpts used at creation time (e.g. in a database) to ensure they can be reproduced exactly for removal.
    costmediumin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[9][10]
  • Queue.removeRepeatableByKey · remove-repeatable-by-key-silent-noop
    warning
    WhenremoveRepeatableByKey() called with a key that no longer exists or was already removed
    Required handlingFor idempotent cleanup, the silent no-op behavior is acceptable by design. For critical schedule removal, verify with queue.getRepeatableJobs() after calling. Log the key being removed so that failed removals can be diagnosed via monitoring.
    costlowin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[9]
  • Job.progress · progress-job-key-missing
    error
    Whenjob.progress(value) called after the job has been deleted from Redis (e.g. removeOnComplete/removeOnFail removed it, or Redis eviction)
    Required handlingCallers SHOULD wrap job.progress() in try-catch within the processor function. Alternatively, avoid calling progress() after performing operations that might cause the job to be considered complete (e.g. don't call progress after the business operation that marks success — the job may have been cleaned up).
    costlowin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[11]
  • Job.progress · progress-lock-lost
    error
    Whenjob.progress(value) called after the job's lock has expired or been taken by another worker
    Required handlingCaller SHOULD check for this error in progress() and immediately abort the processor function to prevent further duplicate work: try { await job.progress(50); } catch (err) { if (err.message.includes('Missing lock')) return; throw err; } Increase lockDuration and lockRenewTime to prevent premature lock expiry.
    costmediumin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[11]
  • Job.update · update-job-not-active
    error
    Whenjob.update(data) called on a job that is no longer in the active state
    Required handlingCaller MUST wrap job.update() in try-catch. Store critical intermediate state in external storage (database) rather than relying solely on job.update() — Redis TTL and job removal policies can destroy this data unexpectedly.
    costmediumin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[12][11]
  • Job.remove · remove-active-job-error
    error
    Whenjob.remove() called on a job that is currently being processed (active/locked)
    ThrowsError('Could not remove job <id>')
    Required handlingCaller MUST wrap job.remove() in try-catch. Check job state before removal: const state = await job.getState(); if (state !== 'active') { await job.remove(); } For active jobs, consider using queue.pause() to stop new jobs, then wait for the active job to finish before removing it.
    costlowin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[12]
  • Job.promote · promote-not-delayed-error
    error
    Whenjob.promote() called on a job that is not in the delayed state
    ThrowsError('Job <id> is not in a delayed state')
    Required handlingCaller MUST wrap job.promote() in try-catch. Check job.getState() immediately before calling promote() and handle the 'not-delayed' error gracefully: const state = await job.getState(); if (state === 'delayed') { await job.promote(); }
    costlowin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[12]
  • Job.log · log-job-key-missing
    warning
    Whenjob.log(row) called after the job has been deleted from Redis
    Required handlingCaller SHOULD await job.log() and wrap in try-catch, or attach .catch() to the returned promise. Pattern: await job.log('Step complete').catch(err => logger.warn('Log failed:', err.message)); This prevents unhandled rejections without blocking the processor.
    costlowin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[11]
  • Job.finished · finished-job-failed
    error
    WhenAwaiting Job.finished() when the job transitions to failed state
    Required handlingCaller MUST wrap await job.finished() in try-catch. On rejection, inspect the error message (which equals job.failedReason) and handle accordingly.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[1]
  • Job.finished · finished-queue-closing
    error
    WhenQueue.close() is called while job.finished() promise is pending
    Required handlingCaller MUST wrap await job.finished() in try-catch. During shutdown, handle the queue-closing rejection gracefully rather than crashing. Consider a timeout pattern: Promise.race([job.finished(), shutdownTimeout]).
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[1]
  • Queue.whenCurrentJobsFinished · when-current-jobs-not-awaited-before-close
    error
    WhenQueue.close() called without first awaiting Queue.whenCurrentJobsFinished()
    Required handlingCaller MUST await queue.whenCurrentJobsFinished() before calling queue.close() in shutdown handlers: process.on('SIGTERM', async () => { await queue.whenCurrentJobsFinished(); await queue.close(); process.exit(0); });
    costhighin prodsilent failureusers seelost datavisibilitysilent
    Sources[6]
  • Queue.whenCurrentJobsFinished · when-current-jobs-no-sigterm-handler
    warning
    WhenwhenCurrentJobsFinished() not called in SIGTERM/SIGINT handler
    Required handlingRegister shutdown handlers that call whenCurrentJobsFinished() before exit: process.once('SIGTERM', async () => { await queue.whenCurrentJobsFinished(); await queue.close(); process.exit(0); });
    costmediumin prodsilent failureusers seelost datavisibilitysilent
    Sources[6]
  • Queue.removeJobs · remove-jobs-no-try-catch
    error
    Whenawait queue.removeJobs(pattern) called without try-catch
    Throwsioredis Error (Redis connection loss or queue.isReady() rejection)
    Required handlingCaller MUST wrap await queue.removeJobs(pattern) in try-catch. On Redis connection error, log and retry after reconnection. Zero matches is not an error.
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[12]
  • Queue.removeJobs · remove-jobs-pattern-blocks-redis
    warning
    WhenQueue.removeJobs(pattern) called with broad pattern on large Redis keyspace
    Required handlingAvoid removeJobs() with broad patterns in production during peak load. Prefer Queue.clean() with a specific status and grace period for bulk cleanup. If removeJobs() is required, use narrow patterns or schedule during off-peak hours.
    costmediumin proddegraded serviceusers seedegraded performancevisibilityvisible
    Sources[12]
  • Job.releaseLock · release-lock-not-owner
    error
    Whenjob.releaseLock() called when current worker does not own the job lock
    ThrowsError('Could not release lock for job <jobId>')
    Required handlingCaller MUST wrap await job.releaseLock() in try-catch. Only call releaseLock() from within the job's own processor function context or after acquiring the lock via job.takeLock(). Do not call releaseLock() from outside the processing context.
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[12]
  • Job.releaseLock · release-lock-no-try-catch
    error
    Whenawait job.releaseLock() called without try-catch
    Throwsioredis Error (Redis connection loss during Lua script execution)
    Required handlingCaller MUST wrap await job.releaseLock() in try-catch. Log and handle appropriately. On Redis connection error, do not retry — the lock expires via TTL automatically.
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[12]
  • Job.moveToFailed · move-to-failed-no-try-catch
    error
    Whenawait job.moveToFailed(errorInfo) called without try-catch
    ThrowsError (Redis Lua script error codes: -1 missing key, -3 wrong state, -6 lock mismatch)
    Required handlingCaller MUST wrap await job.moveToFailed({ message: err.message }) in try-catch. On -1 (missing key): job was already cleaned up — log and skip. On -3 (wrong state): job completed via another path — log and skip. On -6 (lock mismatch): another worker took ownership — do not retry.
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[12][11]
  • Job.moveToFailed · move-to-failed-retry-not-exhausted
    warning
    Whenjob.moveToFailed() called when job still has remaining retry attempts
    Required handlingIf the intent is to force the job to the failed state immediately (bypassing retries), call job.discard() first, then job.moveToFailed(). Without discard(), the job will retry per its configured attempts and backoff settings.
    costlowin prodsilent failureusers seelost datavisibilitysilent
    Sources[12]
  • Job.extendLock · extend-lock-zero-return-unchecked
    error
    Whenawait job.extendLock(duration) is called without checking the return value, and the function returns 0 (lock not extended because lock expired or was taken by another worker).
    ThrowsDoes not throw — returns 0 silently when lock extension fails
    Required handlingCaller MUST check the return value of extendLock(): const extended = await job.extendLock(30000); if (\!extended) { // Another worker took the job — stop processing immediately throw new Error('Lock lost: job taken by another worker'); } Without this check, duplicate processing causes duplicate emails, duplicate payments, or data corruption in distributed queue deployments.
    costhighin prodsilent failureusers seelost datavisibilitysilent
    Sources[13][6]
  • Job.extendLock · extend-lock-no-try-catch
    warning
    Whenawait job.extendLock(duration) called without try-catch and Redis connection is unavailable or the Redis command fails.
    ThrowsError (ioredis connection error or NOSCRIPT error if Lua script not loaded)
    Required handlingWrap extendLock() calls in try-catch. On Redis error, abort the current job processing and let Bull's stall detection handle requeuing: try { const extended = await job.extendLock(this.settings.lockDuration); if (\!extended) throw new Error('Lock lost'); } catch (err) { logger.error('Lock renewal failed, aborting job', err); return; }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[6]
  • Queue.getMetrics · get-metrics-metrics-not-enabled
    warning
    Whenqueue.getMetrics() called but metrics were not enabled in QueueOptions (no `metrics` key in queue constructor options).
    ThrowsDoes not throw — returns zeroed data structure silently
    Required handlingEnsure metrics are enabled in the queue constructor: const queue = new Queue('jobs', { metrics: { maxDataPoints: 1440 } }); Document to callers that zero results may mean metrics are disabled, not that no jobs have run.
    costlowin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[14][15]
  • Queue.getMetrics · get-metrics-no-try-catch
    warning
    Whenawait queue.getMetrics('completed') called without try-catch and Redis pipeline exec returns an error in the first or second result.
    ThrowsError (Redis pipeline error from multi.exec)
    Required handlingWrap getMetrics() in try-catch in dashboard/monitoring code: try { const metrics = await queue.getMetrics('completed'); return metrics.data; } catch (err) { logger.error('Failed to fetch queue metrics:', err); return []; }
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[14]
  • Job.moveToCompleted · move-to-completed-return-value-not-serializable
    error
    Whenjob.moveToCompleted(returnValue) called with a non-JSON-serializable returnValue
    ThrowsTypeError: Converting circular structure to JSON / TypeError: Do not know how to serialize a BigInt
    Required handlingCaller MUST ensure the returnValue is JSON-serializable before calling moveToCompleted(). Use try-catch and validate the return value: // WRONG — throws if result contains circular references or BigInt await job.moveToCompleted(processResult); // CORRECT — validate before completing try { JSON.stringify(processResult); // test serialization } catch (err) { // Return a safe summary instead of the full object await job.moveToCompleted({ status: 'completed', error: 'result-not-serializable' }); return; } await job.moveToCompleted(processResult); Common causes: circular references in database ORM objects (Prisma result objects may have circular prototype chains), BigInt fields from PostgreSQL, or custom class instances with non-serializable properties.
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[12]
  • Job.moveToCompleted · move-to-completed-no-try-catch
    error
    Whenawait job.moveToCompleted() called without try-catch, and the job is no longer in the active state, the lock has expired, or the lock is owned by another worker.
    ThrowsError('Missing key for job <id> finished') — job deleted from Redis (removeOnComplete, eviction); Error('Missing lock for job <id> finished') — lock expired before completion; Error('Job <id> is not in the active state. finished') — job completed/failed via another path; Error('Lock mismatch for job <id>...') — another worker took the lock.
    Required handlingCaller MUST wrap await job.moveToCompleted() in try-catch. Handle each error type: - Missing key: log and skip (job was cleaned up by another mechanism) - Missing lock / Lock mismatch: another worker owns the job — abort immediately to prevent duplicate completion; do not re-attempt moveToCompleted. - Wrong state: job already completed or failed — check getState() before retrying. try { await job.moveToCompleted(returnValue); } catch (err) { if (err.message.includes('Missing lock') || err.message.includes('Lock mismatch')) { logger.warn('Lock lost before completion, aborting', { jobId: job.id }); return; // Stop processing — another worker will handle this job } if (err.message.includes('Missing key')) { logger.warn('Job cleaned up before completion', { jobId: job.id }); return; // Job was removed (removeOnComplete with short TTL) } throw err; // Unexpected error — re-throw }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[12][11]
  • Job.takeLock · take-lock-false-return-unchecked
    error
    Whenawait job.takeLock() is called without checking the return value, and returns false (lock held by another worker or lock already acquired by this worker).
    ThrowsDoes not throw — returns false silently when lock cannot be acquired
    Required handlingCaller MUST check the return value. false means the lock could not be acquired: // WRONG — proceeds even if lock was not acquired await job.takeLock(); await doExclusiveWork(); // NOT safe — another worker may also be doing this! // CORRECT — check return value before proceeding const lock = await job.takeLock(); if (!lock) { logger.warn('Could not acquire lock for job, another worker owns it', { jobId: job.id }); return; // Abort — do not proceed with exclusive work } await doExclusiveWork(); await job.releaseLock(); // Always release in finally block Failing to check the return value leads to duplicate processing when multiple workers attempt to take the same lock — the same critical race condition as extendLock returning 0 without being checked.
    costhighin prodsilent failureusers seelost datavisibilitysilent
    Sources[12]
  • Job.takeLock · take-lock-no-try-catch
    warning
    Whenawait job.takeLock() called without try-catch and Redis connection is unavailable
    ThrowsError (ioredis connection error or NOSCRIPT error if Lua script not cached)
    Required handlingWrap await job.takeLock() in try-catch. On Redis error, do not proceed with exclusive work — treat it as a lock acquisition failure and abort: let lock; try { lock = await job.takeLock(); } catch (err) { logger.error('Redis error acquiring job lock', { jobId: job.id, err }); return; // Cannot determine lock state — abort } if (!lock) { return; } // Lock held by another worker
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[12]
  • Queue.pause · pause-no-try-catch
    error
    Whenawait queue.pause() called without try-catch and Redis connection is unavailable
    ThrowsError (ioredis connection error from isReady() or scripts.pause() Lua execution)
    Required handlingWrap await queue.pause() in try-catch in shutdown handlers and maintenance scripts. A Redis error during global pause means the pause state was NOT persisted — other workers will continue processing jobs. Retry or alert on failure: try { await queue.pause(); logger.info('Queue paused globally'); } catch (err) { logger.error('Failed to pause queue — workers may still be processing', err); // Alert ops team — jobs are still processing despite shutdown attempt }
    costmediumin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[6]
  • Queue.pause · pause-local-hangs-on-stuck-job
    warning
    Whenqueue.pause(true) called with isLocal=true (without doNotWaitActive=true), and there is a job currently in the active state that never completes (stuck/hung processor).
    ThrowsDoes not throw — hangs indefinitely awaiting whenCurrentJobsFinished()
    Required handlingWhen using local pause in shutdown handlers, always set a timeout and use doNotWaitActive=true for immediate termination, or race with a timeout: // RISKY — hangs forever if a job is stuck await queue.pause(true); // SAFE — use doNotWaitActive for immediate local pause await queue.pause(true, true); // Returns immediately without waiting // SAFE — with timeout for graceful-but-bounded shutdown await Promise.race([ queue.pause(true), new Promise((_, reject) => setTimeout(() => reject(new Error('Pause timeout')), 30000)) ]).catch(() => queue.pause(true, true)); // Force pause if timeout
    costmediumin proddelayed failureusers seeservice unavailablevisibilityvisible
    Sources[6]
  • Queue.getNextJob · get-next-job-no-try-catch
    error
    Whenawait queue.getNextJob() called without try-catch and Redis connection is unavailable
    ThrowsError (ioredis connection error from BRPOPLPUSH or scripts.moveToActive Lua execution)
    Required handlingWrap await queue.getNextJob() in try-catch in custom worker loops. On Redis error, the function rethrows (except in the local-pause force-disconnect case which is silently swallowed). A worker loop that does not catch will crash the process or leave the worker stuck: // WRONG — worker process crashes on first Redis blip while (running) { const job = await queue.getNextJob(); if (job) await processJob(job); } // CORRECT — catch and back off on Redis errors while (running) { let job; try { job = await queue.getNextJob(); } catch (err) { logger.error('getNextJob failed; backing off 5s', err); await new Promise(r => setTimeout(r, 5000)); continue; } if (job) { try { await processJob(job); } catch (err) { await job.moveToFailed({ message: err.message }); } } }
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[6]
  • Queue.getNextJob · get-next-job-not-finalized
    error
    Whenawait queue.getNextJob() returns a Job, but the caller does not transition the job to a terminal state (moveToCompleted, moveToFailed, retry, or remove) before the worker exits or the lockDuration expires.
    ThrowsDoes not throw — job remains in active list, becomes stalled
    Required handlingEvery getNextJob() result that returns a Job MUST be transitioned to a terminal state. Use try/finally to guarantee finalization on processor exceptions: const job = await queue.getNextJob(); if (!job) return; try { const result = await processJob(job); await job.moveToCompleted(result, true); } catch (err) { await job.moveToFailed({ message: String(err) }); } Failing to finalize causes the StalledChecker to reschedule the job (often duplicating execution) after lockDuration elapses — same failure mode as missing 'stalled' event listener.
    costhighin prodsilent failureusers seelost datavisibilitysilent
    Sources[6][12]
  • Queue.setWorkerName · set-worker-name-silently-swallowed-on-disabled-client
    warning
    Whenawait queue.setWorkerName() called against a Redis instance that disables the CLIENT command (AWS ElastiCache, some Redis Enterprise Cloud tiers, managed Redis plans that strip dangerous commands).
    ThrowsDoes not throw — promise resolves with undefined; worker name NOT registered with Redis
    Required handlingCode that relies on CLIENT-LIST monitoring (e.g. queue.getWorkers() introspection, ops dashboards counting active workers via Redis CLIENT LIST) must not assume setWorkerName() actually registered the name. The promise resolves successfully regardless. To detect this case, attempt to read back the worker list after setWorkerName() and compare: await queue.setWorkerName(); const workers = await queue.getWorkers(); // workers may be undefined OR may not include this connection name // — both indicate CLIENT is disabled. Fall back to internal tracking. Most production code does not need to detect this; the silent swallow is by design. But callers building admin/observability tooling on top of CLIENT LIST must be aware that worker names may not appear.
    costlowin prodsilent failureusers seedegraded performancevisibilitysilent
    Sources[16]
  • Queue.setWorkerName · set-worker-name-no-try-catch
    warning
    Whenawait queue.setWorkerName() called without try-catch and Redis is unreachable / auth failed / blocked
    ThrowsError (ioredis connection error from isRedisReady, or any non-CLIENT Redis error from CLIENT SETNAME)
    Required handlingWhen invoking setWorkerName() manually (outside of the auto-call during Queue.process() init), wrap in try-catch in startup/initialization code: try { await queue.setWorkerName(); } catch (err) { logger.warn('setWorkerName failed (non-CLIENT error)', err); // Continue startup — name registration is observability, not correctness } Note: the Queue.process() internal call path already swallows this via a different code path (process() does not await setWorkerName's rejection), so the manual-call site is the only place where this throws.
    costlowin prodimmediate exceptionusers seedegraded performancevisibilityvisible
    Sources[16]
  • Queue.getWorkers · get-workers-returns-undefined-when-client-disabled
    error
    Whenawait queue.getWorkers() called against a Redis instance that disables the CLIENT command. The promise resolves to undefined, NOT an empty array — TypeScript type signature claims Promise<Array<...>> but runtime can be undefined.
    ThrowsDoes not throw — returns undefined silently
    Required handlingCallers MUST defensively check for undefined before iterating. The d.ts type is misleading — it claims Array<{...}> but the implementation can return undefined: // WRONG — crashes with "Cannot read properties of undefined (reading 'length')" const workers = await queue.getWorkers(); console.log(`${workers.length} workers`); // CORRECT — coalesce to empty array const workers = (await queue.getWorkers()) ?? []; console.log(`${workers.length} workers`); Particularly important for admin dashboards deployed to environments using AWS ElastiCache, Upstash, or other managed Redis where CLIENT is restricted.
    costmediumin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[16]
  • Queue.getWorkers · get-workers-no-try-catch
    warning
    Whenawait queue.getWorkers() called without try-catch and Redis is unreachable
    ThrowsError (ioredis connection error from isRedisReady or CLIENT LIST)
    Required handlingWrap await queue.getWorkers() in try-catch when called from monitoring endpoints or admin UIs. Connection failures should not crash the request: app.get('/admin/workers', async (req, res) => { try { const workers = (await queue.getWorkers()) ?? []; res.json({ workers }); } catch (err) { logger.error('getWorkers failed', err); res.status(503).json({ error: 'Redis unavailable' }); } });
    costlowin prodimmediate exceptionusers seeservice unavailablevisibilityvisible
    Sources[16]

Sources

Every postcondition cites at least one of these. Grouped by source type; numbered to match the footnotes above.

Official documentation
Source code
Issues & pull requests

Research notes

Curator notes from SOURCES.md captured when the profile was written so you can verify the reasoning, not just the rules.

Sources: bull

Last Updated: 2026-02-27 Package Version: >=3.0.0 <5.0.0 Research Quality: ⭐⭐⭐⭐⭐ (comprehensive)


Official Documentation


Error Handling Patterns

Bull provides THREE error handling mechanisms:

1. Job Processor Errors

Callback-based processors:

queue.process(function(job, done) {
  // Handle errors by calling done(error)
  performWork(job.data, (err, result) => {
    if (err) {
      done(err);  // ✅ Proper error handling
    } else {
      done(null, result);
    }
  });
});

Promise-based processors:

queue.process(async (job) => {
  try {
    const result = await performWork(job.data);
    return result;  // ✅ Proper error handling
  } catch (error) {
    throw error;  // ✅ Bull captures this
  }
});

CRITICAL: Bull automatically captures unhandled exceptions in processors and marks jobs as failed.

2. Event Listeners (REQUIRED)

Bull emits events for failure scenarios. You MUST listen to these events:

Failed event:

queue.on('failed', (job, err) => {
  // ✅ REQUIRED - log to monitoring system
  console.error(`Job ${job.id} failed:`, err.message);
});

Stalled event (CRITICAL):

queue.on('stalled', (job) => {
  // ✅ CRITICAL - indicates jobs being double-processed\!
  console.error(`Job ${job.id} stalled - CPU-intensive code detected`);
});

Error event:

queue.on('error', (error) => {
  // ✅ REQUIRED - Redis connection errors, queue errors
  console.error('Queue error:', error);
});

Why critical: Without these listeners, failed jobs are silently lost and stalled jobs cause duplicate processing (e.g., sending duplicate emails, charging customers twice).

3. Global Events (Multi-Worker)

For distributed systems with multiple workers:

// Listen across ALL workers
queue.on('global:failed', (jobId, err) => {
  console.log(`Job ${jobId} failed on any worker`);
});

queue.on('global:stalled', (jobId) => {
  console.log(`Job ${jobId} stalled on any worker`);
});

Use case: Centralized monitoring, alerting systems


Error Types

1. Job Processing Errors

When: Processor function throws/rejects Result: Job moved to "failed" status, retry attempted (if configured) Handling: try-catch or done(error)

2. Stalled Jobs (MOST DANGEROUS)

Definition: Job being processed but Bull suspects processor has hanged

Cause: CPU-intensive synchronous code blocking event loop, preventing lock renewal

Symptoms:

  • Job marked as "stalled more than allowable limit"
  • Job automatically restarted by another worker
  • CRITICAL IMPACT: Job processed multiple times (duplicate side effects)

Example scenario:

queue.process(async (job) => {
  // ❌ BAD - synchronous CPU-intensive code
  for (let i = 0; i < 1000000000; i++) {
    // This blocks event loop for seconds
  }
  // Lock expires, job marked stalled, another worker picks it up
});

Prevention:

  • Break CPU-intensive work into async chunks
  • Use setImmediate() to yield event loop
  • Increase lockDuration setting (tradeoff: slower stalled detection)
  • ALWAYS listen to stalled event

3. Redis Connection Errors

When: Redis unavailable, network issues Result: Queue operations fail, jobs can't be added/processed Handling: Listen to error event on queue instance

4. Timeout Errors

When: Job exceeds configured timeout Result: Job killed, moved to failed Handling: Configure timeout in job options, handle in failed event


Common Production Bugs

Bug #1: Mixing Callbacks and Promises (CRITICAL)

Symptom: Jobs stall indefinitely, subsequent jobs never processed

Cause: Mixing callback-based and promise-based code in processor

Example (WRONG):

queue.process(async (job) => {
  action1(job.data, (err1, result1) => {  // Callback
    // ...
  });
  return action2(job.data);  // Promise
  // ❌ Race condition - job may complete before action1 finishes
});

Fix: Use async/await consistently:

queue.process(async (job) => {
  const result1 = await promisify(action1)(job.data);
  const result2 = await action2(job.data);
  return result2;  // ✅ Correct
});

Reference: GitHub Issue #1822

Bug #2: Not Listening to 'stalled' Event (VERY COMMON)

Symptom: Silent double-processing, duplicate side effects

Impact:

  • Emails sent twice
  • Payments charged multiple times
  • Database records duplicated

Fix: ALWAYS add stalled event listener:

queue.on('stalled', (job) => {
  logger.error(`Job ${job.id} stalled - investigate processor performance`);
  // Send to error monitoring (Sentry, Datadog, etc.)
});

Frequency: Estimated 60-70% of production Bull implementations missing this listener

Bug #3: Not Listening to 'failed' Event (COMMON)

Symptom: Failed jobs silently lost, no visibility

Impact: Jobs fail but no one knows, errors accumulate undetected

Fix:

queue.on('failed', (job, err) => {
  logger.error(`Job ${job.id} failed:`, err);
  // Send to monitoring
});

Bug #4: Queue Instance Leaks (COMMON)

Symptom: Gradual memory/connection exhaustion, Redis connection limit reached

Cause: Creating new Queue instances repeatedly (per-request, in loops)

Example (WRONG):

app.post('/send-email', (req, res) => {
  const queue = new Queue('emails', redisConfig);  // ❌ New instance per request
  queue.add(req.body);
});

Fix: Instantiate once, reuse:

const emailQueue = new Queue('emails', redisConfig);  // ✅ Global/module scope

app.post('/send-email', (req, res) => {
  emailQueue.add(req.body);  // ✅ Reuse
});

// On shutdown:
process.on('SIGTERM', async () => {
  await emailQueue.close();  // ✅ Clean up
});

Reference: GitHub Issue #1822

Bug #5: Calling job.moveToFailed() from Processor (UNCOMMON)

Symptom: 'failed' event not emitted, race conditions

Cause: Manually calling job.moveToFailed() from within processor

Why wrong: Only queue.js should finalize jobs, not processor code

Example (WRONG):

queue.process(async (job) => {
  try {
    await performWork(job.data);
  } catch (error) {
    await job.moveToFailed({ message: 'failed' });  // ❌ WRONG
  }
});

Fix: Just throw the error:

queue.process(async (job) => {
  // ✅ Bull handles finalization
  await performWork(job.data);  // Throws on error
});

Reference: GitHub Issue #1104

Bug #6: CPU-Intensive Synchronous Code (COMMON)

Symptom: Jobs marked stalled, double-processed

Cause: Long-running synchronous operations blocking event loop

Fix: Break into chunks or use worker threads:

// ❌ WRONG
for (let i = 0; i < 1000000; i++) {
  // Synchronous work
}

// ✅ CORRECT
async function processInChunks(total) {
  const chunkSize = 10000;
  for (let i = 0; i < total; i += chunkSize) {
    await new Promise(resolve => setImmediate(resolve));  // Yield event loop
    // Process chunk
  }
}

Best Practices

1. Always Define Event Listeners

Minimum required:

queue.on('failed', (job, err) => { /* log */ });
queue.on('stalled', (job) => { /* log */ });
queue.on('error', (error) => { /* log */ });

2. Use Consistent Async Pattern

Prefer: async/await throughout Avoid: Mixing callbacks and promises

3. Configure Retries and Backoff

queue.add(data, {
  attempts: 3,
  backoff: {
    type: 'exponential',
    delay: 2000
  }
});

4. Instantiate Queues Once

  • Create at application startup
  • Store in module scope
  • Call .close() on shutdown

5. Monitor Stalled Jobs

  • Set up alerting on stalled event
  • Investigate root cause (CPU-intensive code)
  • Adjust lockDuration if needed

Important Note

Bull is in maintenance mode. For new projects, consider BullMQ (TypeScript rewrite with new features). However, Bull remains stable and widely used in production.


Contract Rationale

Postcondition: missing-error-handler

Bull job processors perform async operations that can fail. Unhandled errors cause jobs to fail silently or stall indefinitely. The documentation and GitHub issues emphasize:

  1. Processor errors must be handled (try-catch or done(error))
  2. Event listeners are REQUIRED (failed, stalled, error)
  3. Stalled jobs are CRITICAL - indicate double-processing

Citations:


Research Metadata

  • Research Date: 2026-02-27
  • Researcher: Claude Sonnet 4.5
  • Documentation Sources: 6 URLs
  • GitHub Issues Analyzed: 2+
  • Common Mistakes Documented: 6
  • Line Count: 320+ lines (target 100+ ✅)
Need a different package?
Request a profile