Typed BullMQ queues for NestJS
BullMQ 5 · Workers · Flows · Job Schedulers · Deduplication · OpenTelemetry · Graceful Shutdown · Zero Runtime Dependencies
GitHub · Issues · Quick Start · API Reference · Example App
@bymax-one/nest-queue is a NestJS dynamic module that wraps BullMQ 5 behind a typed,
opinionated surface. Instead of wiring ioredis connections, per-queue registration, and
maxRetriesPerRequest policy by hand, you install one library and get typed producers,
decorator-based workers, flows, recurring Job Schedulers, native deduplication,
OpenTelemetry passthrough, and a tested bounded graceful-shutdown protocol.
The library has zero direct dependencies — bullmq, ioredis, and @nestjs/* arrive as
peer dependencies, so you control exact versions and the supply-chain surface stays minimal.
- One setup, every queue. A single
forRoot/forRootAsync; queues are created on demand by name — noregisterQueue()per queue. - Correct connections by default. Dual-mode (bring-your-own client or lib-owned) with the
per-role
maxRetriesPerRequestpolicy applied automatically — the Queue connection keeps default retries (fail fast), only the duplicated Worker/QueueEvents connections forcenull. - Current BullMQ API only. Recurring jobs use Job Schedulers (
upsertJobScheduler), never the removedaddRepeatable. Cron is validated by BullMQ's own parser, never a hand-rolled regex. - Safe shutdown. A bounded-drain protocol with at-least-once semantics, force-close, and stalled-job accounting — verified against a real Redis (Testcontainers) E2E suite.
- Typed producer API —
enqueue<TData, TResult>(),enqueueBulk(bounded),getJob,getJobs,getMetrics,pauseQueue/resumeQueue/cleanQueue. - Decorator workers —
@Processor,@Process,@OnWorkerEvent,@OnQueueEvent, discovered automatically from your providers; plus a programmaticWorkerRegistryand file-based sandboxed processors. - Flows — opt-in
FlowServiceover BullMQFlowProducerwith correct fail-parent semantics. - Job Schedulers — cron (incl. 6-field seconds) and interval recurrence via
upsertJobScheduler. - Deduplication — native BullMQ simple/throttle/debounce modes, passed straight through.
- OpenTelemetry — optional
bullmq-oteltelemetry passthrough across enqueue → handler. - Metrics — opt-in
MetricsServicewith a configurable TTL cache. - Graceful shutdown — bounded drain → force-close, wired via NestJS
onApplicationShutdown. - Dual subpath exports —
.(server) and./shared(zero-dependency types/constants).
pnpm add @bymax-one/nest-queue bullmq ioredisWire the module in your root AppModule:
import { Module } from '@nestjs/common'
import { BymaxQueueModule } from '@bymax-one/nest-queue'
@Module({
imports: [
BymaxQueueModule.forRoot({
connection: { url: process.env.REDIS_URL },
isGlobal: true,
}),
],
})
export class AppModule {}Enqueue a job from any service:
import { Injectable } from '@nestjs/common'
import { QueueService } from '@bymax-one/nest-queue'
@Injectable()
export class EmailService {
constructor(private readonly queue: QueueService) {}
async sendWelcome(userId: string): Promise<void> {
await this.queue.enqueue<{ userId: string }, void>('email', 'welcome', { userId })
}
}Process jobs with a decorated handler:
import { Injectable } from '@nestjs/common'
import { Processor, Process } from '@bymax-one/nest-queue'
import type { Job } from '@bymax-one/nest-queue'
@Injectable()
@Processor('email')
export class EmailProcessor {
@Process('welcome')
async handleWelcome(job: Job<{ userId: string }, void>): Promise<void> {
// handle job.data.userId
}
}Register the processor in your module alongside BymaxQueueModule:
@Module({
providers: [EmailProcessor],
})
export class EmailModule {}Mode A accepts an existing ioredis client injected from your cache or connection layer.
Worker and QueueEvents connections are duplicated from this client with
maxRetriesPerRequest: null applied automatically; the Queue connection uses ioredis defaults.
import { BymaxQueueModule } from '@bymax-one/nest-queue'
import { CACHE_REDIS_CLIENT } from '@bymax-one/nest-cache'
import type { Redis } from 'ioredis'
BymaxQueueModule.forRootAsync({
inject: [CACHE_REDIS_CLIENT],
useFactory: (queueRedis: Redis) => ({
connection: { client: queueRedis },
isGlobal: true,
}),
})Mode A is the recommended setup when you already have @bymax-one/nest-cache in the app.
One client is shared; the lib duplicates it per role and applies the correct retry policy
without touching your cache connection.
Mode B lets the library own the Redis connections. Pass either a URL or raw ioredis options:
// URL form
BymaxQueueModule.forRoot({
connection: { url: process.env.REDIS_URL },
isGlobal: true,
})
// Options form
BymaxQueueModule.forRoot({
connection: {
options: {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
password: process.env.REDIS_PASSWORD,
tls: {},
},
},
isGlobal: true,
})The library opens one connection for queues and one per worker/QueueEvents listener, each
with maxRetriesPerRequest: null where BullMQ requires it.
The central interaction point with BullMQ queues.
import { QueueService } from '@bymax-one/nest-queue'
import type { Job, JobsOptions, JobSchedulerRepeatOptions, QueueMetrics } from '@bymax-one/nest-queue'| Method | Signature | Description |
|---|---|---|
enqueue |
<TData, TResult>(queue, name, data, opts?) → Promise<Job<TData, TResult>> |
Add a single job |
enqueueBulk |
<TData, TResult>(queue, jobs) → Promise<Job<TData, TResult>[]> |
Add up to 1 000 jobs atomically |
getJob |
<TData, TResult>(queue, jobId) → Promise<Job<TData, TResult> | undefined> |
Get job by ID |
getJobs |
<TData, TResult>(queue, statuses, start?, end?) → Promise<Job<TData, TResult>[]> |
Get jobs by status |
getMetrics |
(queue) → Promise<QueueMetrics> |
Snapshot of counts + processed/failed rates |
pauseQueue |
(queue) → Promise<void> |
Pause a queue |
resumeQueue |
(queue) → Promise<void> |
Resume a paused queue |
cleanQueue |
(queue, grace, limit, status?) → Promise<string[]> |
Remove finished jobs older than grace ms |
upsertJobScheduler |
(queue, id, repeat, template?) → Promise<void> |
Create or update a recurring Job Scheduler |
removeJobScheduler |
(queue, id) → Promise<boolean> |
Remove a Job Scheduler by ID |
getJobSchedulers |
(queue, opts?) → Promise<JobSchedulerJson[]> |
List all Job Schedulers for a queue |
getOrCreateQueue |
<TData, TResult>(queue, overrides?) → Queue<TData, TResult> |
Return the cached raw Queue instance |
Programmatic API to create and manage BullMQ workers at runtime.
import { WorkerRegistry } from '@bymax-one/nest-queue'
import type { ProgrammaticWorkerConfig, SandboxedWorkerConfig } from '@bymax-one/nest-queue'
// Register an in-process worker
registry.register({
queueName: 'email',
processor: async (job) => { /* … */ },
options: { concurrency: 4 },
})
// Register a sandboxed (out-of-process) processor
registry.registerSandboxed({
queueName: 'heavy-compute',
processorFile: path.resolve(__dirname, 'compute.processor.js'),
options: { concurrency: 2 },
})Enable flows in the module options and inject the service:
BymaxQueueModule.forRoot({
connection: { url: process.env.REDIS_URL },
flows: { enabled: true },
})
// Then inject:
import { FlowService } from '@bymax-one/nest-queue'
import type { FlowJob, JobNode } from '@bymax-one/nest-queue'
const tree: JobNode = await this.flows.add({
name: 'process-order',
queueName: 'orders',
data: { orderId: '123' },
children: [
{ name: 'charge-payment', queueName: 'payments', data: {} },
{ name: 'send-confirmation', queueName: 'email', data: {} },
],
})Provide a per-queue snapshot with a configurable TTL cache:
BymaxQueueModule.forRoot({
connection: { url: process.env.REDIS_URL },
metrics: { enabled: true, cacheTtlMs: 5_000 },
})
import { MetricsService } from '@bymax-one/nest-queue'
import type { QueueMetrics } from '@bymax-one/nest-queue'
const metrics: QueueMetrics = await this.metricsService.getMetrics('email')
const all = await this.metricsService.getAll(['email', 'notifications'])Marks a class as a worker bound to queueName. The class must be an @Injectable() registered as a NestJS provider.
Method decorator for the handler. If jobName is omitted, the method handles all jobs on the queue. If provided, only jobs with that name are dispatched here.
@Process()
async handleAll(job: Job): Promise<void> { /* … */ }
@Process('send-email')
async handleSendEmail(job: Job<EmailPayload>): Promise<void> { /* … */ }Listens to BullMQ worker-local events ('completed', 'failed', 'progress', 'active', 'stalled', …).
@OnWorkerEvent('failed')
onFailed(job: Job | undefined, err: Error): void {
console.error(`Job ${job?.id} failed:`, err.message)
}Listens to BullMQ QueueEvents (global, cross-process events). Requires queueEvents: { enabled: true } in module options.
@OnQueueEvent('completed')
onCompleted(jobId: string, returnValue: string): void {
console.log(`Job ${jobId} completed`)
}Job Schedulers are the current recurring-jobs API in BullMQ (superseding the removed addRepeatable API). Use upsertJobScheduler to create or update a scheduler:
// Cron-based (every day at 9 AM)
await this.queue.upsertJobScheduler(
'notifications',
'daily-digest',
{ pattern: '0 9 * * *' },
{ name: 'send-digest', data: { type: 'daily' } },
)
// Interval-based (every 5 minutes)
await this.queue.upsertJobScheduler(
'health-check',
'ping',
{ every: 5 * 60 * 1000 },
{ name: 'ping', data: {} },
)
// Remove
await this.queue.removeJobScheduler('notifications', 'daily-digest')
// List
const schedulers = await this.queue.getJobSchedulers('notifications')Cron-with-seconds (6-field patterns) is supported. Patterns are validated by BullMQ's
own cron-parser, not a hand-rolled regex.
Pass the deduplication option directly on enqueue — the library delegates the behavior entirely to BullMQ:
// Simple: collapse duplicates until the first in-flight job completes
await this.queue.enqueue('email', 'welcome', { userId }, {
deduplication: { id: `welcome:${userId}` },
})
// Throttle: ignore duplicates for 30 seconds
await this.queue.enqueue('email', 'welcome', { userId }, {
deduplication: { id: `welcome:${userId}`, ttl: 30_000 },
})
// Debounce: keep only the latest; each duplicate resets the TTL
await this.queue.enqueue('email', 'welcome', { userId }, {
deduplication: { id: `welcome:${userId}`, ttl: 5_000, extend: true, replace: true },
})Pass a bullmq-otel telemetry instance (optional peer dep) to trace spans from enqueue through the handler:
import { BullMQOtel } from 'bullmq-otel'
BymaxQueueModule.forRoot({
connection: { url: process.env.REDIS_URL },
telemetry: new BullMQOtel('my-service', tracer),
})QueueLifecycle is registered and invoked automatically via NestJS onApplicationShutdown.
It implements a bounded drain protocol with at-least-once semantics:
- All workers are closed with a grace period (
drainTimeoutMs, default 30 s). - A
Promise.raceresolves either when the worker drains normally or when the timeout fires — whichever comes first. - On timeout, the worker is force-closed (
worker.close(true)), preserving stalled jobs for reprocessing by the next instance. - In Mode B, Redis connections are disconnected after all workers are closed.
Handlers must be idempotent. Use jobId, deduplication, or application-level
idempotency keys to guard against reprocessing a job that was partially handled before
a crash or force-close.
DLQ pattern. When a job exhausts its attempts, move it to a dead-letter queue:
@OnWorkerEvent('failed')
async onFailed(job: Job | undefined, err: Error): Promise<void> {
if (job && job.attemptsMade >= (job.opts.attempts ?? 1)) {
await this.queue.enqueue(`${job.queueName}-dlq`, job.name, job.data, {
jobId: `dlq:${job.id}`,
})
}
}Tune drainTimeoutMs when jobs may run longer than 30 s:
BymaxQueueModule.forRoot({
connection: { url: process.env.REDIS_URL },
shutdown: { drainTimeoutMs: 60_000 },
})| Subpath | Entry | Description |
|---|---|---|
. |
@bymax-one/nest-queue |
Full server surface: module, services, decorators, types |
./shared |
@bymax-one/nest-queue/shared |
Zero-dependency types and constants; safe in any runtime |
| Subpath | Required peers |
|---|---|
. (server) |
@nestjs/common ^11, @nestjs/core ^11, bullmq ^5.16, ioredis ^5, reflect-metadata ^0.2 |
./shared |
None |
| Optional | bullmq-otel ^1 (OpenTelemetry) |
| Concern | @nestjs/bullmq (official) |
@bymax-one/nest-queue |
|---|---|---|
| Module setup | BullModule.forRoot() + registerQueue() per queue |
Single forRoot/forRootAsync; queues created on demand by name |
| Connection | Manual ioredis + maxRetriesPerRequest wiring |
Dual mode (BYO / lib-owned); correct per-role retry policy applied automatically |
| Job defaults | Per registerQueue |
Centralized opinionated defaults with per-queue/per-job overrides |
| Producer API | @InjectQueue + raw queue.add |
Typed enqueue<TData, TResult>(), enqueueBulk, upsertJobScheduler |
| Shutdown | Framework closes workers | Explicit bounded-drain protocol; force-close + stalled accounting |
| Observability | Manual | telemetry passthrough (OpenTelemetry) + MetricsService |
Choose this library when you want a single opinionated setup, the @bymax-one/nest-cache
connection-sharing story, enforced defaults, and a tested bounded-shutdown protocol.
Choose @nestjs/bullmq when you want the thinnest official binding and prefer to
wire connection and defaults yourself.
- Runtime: Node.js 24+
- Framework: NestJS 11 (
ConfigurableModuleBuilder+setExtras) - Queue engine: BullMQ
^5.16(peer) overioredis ^5(peer) - Telemetry:
bullmq-otel ^1(optional peer) - Build: tsup (ESM + CJS dual subpath,
.d.tsfor both) - Tests: Jest +
@testcontainers/redis(E2E) + Stryker (mutation) - TypeScript: 5.x strict (
noUncheckedIndexedAccess,exactOptionalPropertyTypes), zeroany
- 100% line/branch coverage on every implemented file (hard CI gate).
- Mutation score 98.99% (Stryker
break 95) — seedocs/mutation_testing_results.md. - Real-Redis E2E via Testcontainers (workers, flows, schedulers, deduplication, retries, DLQ, graceful shutdown).
- Bundle budgets enforced (
pnpm size): server ≤ 18 KiB brotli, shared ≤ 2.5 KiB. - Supply chain: SHA-pinned GitHub Actions, OSV-Scanner, TruffleHog, OpenSSF Scorecard, npm publish with provenance (OIDC).
pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm sizeThis error fires in Mode A when the injected client was not configured with
maxRetriesPerRequest: null. BullMQ workers require this setting to block on
Redis operations rather than giving up after a few retries.
If you use @bymax-one/nest-cache, pass a queue-dedicated client with maxRetriesPerRequest: null:
// In your cache module, create a second ioredis client for the queue
const queueRedis = new Redis({ ..., maxRetriesPerRequest: null })The library duplicates the client you pass and applies the correct setting; ensure the original client passed to Mode A is usable as a duplication source (ioredis allows duplicate on any state).
Stalled jobs are re-queued automatically by BullMQ's stalled-job check. If workers do not pick them up:
- Verify
drainTimeoutMsis larger than the worst-case job runtime. - Verify
lockDuration(inworkerOptions) covers the handler execution time — a worker that runs longer thanlockDurationwill have its lock stolen, marking the job as stalled. - Check
worker.maxStalledCountis> 0(default1) so stalled jobs re-queue.
- NestJS only — does not work with plain Express, standalone Fastify, or other frameworks.
- Node.js only — Deno and Bun are not supported.
- BullMQ
^5.16(0.1.x floor) — v6 promotion is planned once the E2E suite is green on the v5 + v6 matrix; see the BullMQ version policy inCHANGELOG.md. - No Redis Cluster sharding — BullMQ's cluster support is not exercised by this library.
- No built-in DLQ helper — use the
@OnWorkerEvent('failed')pattern above. - No Prometheus metrics export —
getMetrics()returns snapshots; publish them viaprom-clientin your own health endpoint.
For the full list of limitations and their alternatives, see
docs/technical_specification.md § 16.
Pull requests are welcome. Please open an issue first for significant changes.
- Read
docs/technical_specification.mdfor architecture decisions. - Read
docs/development_plan.mdfor the phased roadmap. - Run the full gate:
pnpm typecheck && pnpm lint && pnpm test:cov:all && pnpm build && pnpm size - Conventional Commits are enforced by
commitlint.config.cjs.
If you discover a security vulnerability, please do not open a public issue. Instead, email us
at support@bymax.one with details. We take security seriously and will respond promptly. See
SECURITY.md for the full policy.
Built with ❤️ by Bymax One