Fanning out large record sets
When you need to background work across a very large number of records (hundreds of thousands to millions), don't enqueue all individual jobs up front. Creating a million jobs at once has several problems:
- Redis memory pressure from holding a million job payloads at once.
- Interruption risk — the enqueuing loop itself can be killed by a deployment,
SIGTERM, or a Node process crash, and if it restarts from the beginning it creates duplicate jobs. - Queue observability collapses — dashboards become unusable.
The two-level fan-out pattern
The idiomatic pattern uses pluckEach and priority levels:
- A kickoff job uses
pluckEachto pluck IDs in batches (default batch size is 1000). - For each batch, it enqueues an expander job (priority
last) with just that batch of IDs. - Each expander job loads its batch and enqueues an individual worker job (priority
not_urgent) per record. - Each individual worker job loads the record and does the real work.
Each service lives in its own file, as always:
// app/services/ReprocessAllPhotosService.ts
export default class ReprocessAllPhotosService extends ApplicationBackgroundedService {
public static override get backgroundJobConfig() {
return { priority: 'last' as const }
}
// Step 1: kickoff — iterates the table in batches and enqueues one expander per batch
public static async reprocessAll() {
await this.background('_reprocessAll')
}
public static async _reprocessAll() {
let batch: string[] = []
await Photo.pluckEach('id', async (id: string) => {
batch.push(id)
if (batch.length >= 1000) {
await this.background('_expandBatch', batch)
batch = []
}
})
if (batch.length > 0) await this.background('_expandBatch', batch)
}
// Step 2: expander — fans the batch into individual worker jobs
public static async _expandBatch(ids: string[]) {
await Photo.where({ id: ids }).findEach(async photo => {
await PhotoProcessingService.processOne(photo)
})
}
}
// app/services/PhotoProcessingService.ts
export default class PhotoProcessingService extends ApplicationBackgroundedService {
public static override get backgroundJobConfig() {
return { priority: 'not_urgent' as const }
}
// Step 3: individual worker — loads the record and does the actual work
public static async processOne(photo: Photo) {
await this.background('_processOne', photo.id)
}
public static async _processOne(photoId: string) {
const photo = await Photo.find(photoId)
if (!photo) return
// ...do the real work
}
}
Why this bounds the backlog
Keep both tiers of the fan-out below default. A bulk run's individual jobs vastly outnumber ordinary application work, and if they ran at default priority they'd compete directly with it — routine, more-important-than-bulk jobs would queue up behind however many thousand photos are left to reprocess. Bulk work belongs entirely under not_urgent/last so it only fills otherwise-idle worker slots.
Because expanders run at last priority, they only claim worker slots when no not_urgent-priority individual jobs are pending. With 10 worker slots (workerCount × concurrency), that means at most ~10 batches are expanded at a time (producing ~10,000 individual jobs in flight), and the individual jobs drain before more batches are expanded. The individual-job backlog stays bounded regardless of the total record count.
pluckEachselects only theidcolumn — no hydration, minimal memory.- Priorities create the backpressure. Expanders (
last) yield to individual jobs (not_urgent), so the in-flight count of individual jobs never exceeds roughlyworker_count * concurrency * batch_size. Priority orders worker slots; it does not throttle a running job's request rate. To bound pressure on an external service, put those jobs on a named workstream with arateLimit. - The expander and individual-worker services must route to the same queue. Priority is only meaningful within a single BullMQ queue — jobs in different queues have separate worker pools and never compete for the same slots, so putting expanders and individual jobs on different queues silently defeats the backpressure; each queue just drains independently and you're back to unbounded fan-out.
- If the kickoff is interrupted, only the expanders already enqueued will run, and if the kickoff job itself is retried it re-plucks from the beginning — so every ID already enqueued in a batch is enqueued again, and
_processOneredoes that record's work when the record is still there. When repeating a record's work is expensive, stamp a datetime column on the record as each one finishes and filter the kickoff'spluckEachquery on it, so a re-pluck skips what is already done. - Individual jobs still follow the standard rule of passing IDs, not model instances — hydrate inside
_processOne. See Services.
Isolating the fan-out to its own queue
To keep this fan-out off the default queue entirely — so it can't crowd out unrelated not_urgent/last work there — route both the expander and the individual-worker service to the same named workstream. The mapped priority is written to BullMQ's top-level priority on every job, grouped or not, so the expander/individual backpressure survives the isolation on open-source BullMQ and on BullMQ Pro alike.
On a shared queue, a check-in/heartbeat job at last waits behind every expander enqueued before it, for most of a long run, so the check-in can miss its window while the workers are healthy. Isolating the fan-out keeps the check-in on time.
If the bulk work calls a rate-limited external API, that same named workstream is where you bound the request rate: give it a rateLimit. Priority cannot do this — it orders worker slots, and once a job is running it does not throttle the requests that job makes.