Tied to mission: a single job can't express "do these N things in parallel, then aggregate." Flows do exactly that — the building block for image processing and webhook fan-out in your mission. Builds on Lesson 1, Lesson 2, Lesson 4.
A flow is a tree of jobs. A parent job sits in a new state, waiting-children, and is not moved to waiting (where a worker can grab it) until all its children have completed successfully. Parent and child jobs are otherwise ordinary jobs.
Trees can be arbitrarily deep — a child can itself have children.
You don't use Queue.add() for flows. You use the FlowProducer class, whose add() inserts the entire tree atomically (all-or-nothing):
import { FlowProducer } from 'bullmq';
const flow = new FlowProducer({ connection });
const tree = await flow.add({
name: 'finalize',
queueName: 'pipeline',
data: { album: 'vacation' },
children: [
{ name: 'resize', data: { size: 200 }, queueName: 'images' },
{ name: 'resize', data: { size: 400 }, queueName: 'images' },
{ name: 'resize', data: { size: 800 }, queueName: 'images' },
],
});
// → adds 4 jobs atomically: 1 to 'pipeline', 3 to 'images'
Notice every node has its own queueName. Children can live in a different queue than the parent — and different from each other. This is flows' superpower: cross-queue dependencies. Put CPU-heavy image work on the images queue (sandboxed workers) and the fast aggregation on pipeline.
The opts on a flow node is a normal JobsOptions minus repeat, debounce, deduplication (and parent). So:
repeat), can't debounce, can't deduplicate. If you need a recurring pipeline, schedule the producer that calls flow.add() via a Job Scheduler (Lesson 3), not the flow nodes themselves.
| Method / field | Returns |
|---|---|
flow.getFlow({ id, queueName, depth, maxChildren }) | The whole subtree: a { job, children } tree. |
job.getChildrenValues() | Map of child key → child's returnvalue. Use in the parent to aggregate. |
job.getDependenciesCount() | Counts of children by state (processed / unprocessed). |
job.parentKey | Fully-qualified key of this job's parent (or undefined). |
// parent worker aggregates children's results
new Worker('pipeline', async (job) => {
const results = await job.getChildrenValues(); // { '<childKey>': 200url, ... }
const urls = Object.values(results);
await sendEmail({ album: job.data.album, urls });
}, { connection });
getFlow takes bounds so deep trees don't explode your memory:
const shallow = await flow.getFlow({
id: parentJob.id,
queueName: 'pipeline',
depth: 1, // only first level of children
maxChildren: 50, // cap per node
});
failed, not completed — so the parent stays in waiting-children forever. It is orphaned. No event, no worker, no email. The pipeline silently stalls.
continueParentOnFailure (best-effort)Set on a child: if that child fails, the parent starts processing immediately instead of waiting for siblings. Pair with removeUnprocessedChildren() to cancel the rest, and getFailedChildrenValues() to detect whether you're here due to a failure.
children: [
{
name: 'resize', data: { size: 200 }, queueName: 'images',
opts: { continueParentOnFailure: true }, // parent proceeds if THIS child fails
},
...
],
// in parent processor:
await job.removeUnprocessedChildren(); // cancel siblings still pending
const failed = await job.getFailedChildrenValues();
if (Object.keys(failed).length) console.warn('partial success');
failParentOnFailure (fail-fast)Set on a child: if that child fails, the parent is moved to failed immediately. It's selective (only children with the flag trigger it) and recursive — if ancestors also carry the flag, the failure propagates up the tree.
children: [
{
name: 'validate', data: { file }, queueName: 'checks',
opts: { failParentOnFailure: true }, // parent fails if validation fails
},
...
],
| Pick | When |
|---|---|
continueParentOnFailure | The pipeline is useful even with partial results (best-effort email with whatever resized). |
failParentOnFailure | The step is mandatory (validation, payment) — no point running the parent. |
| (neither — default) | Usually wrong for any child that can fail. The parent will hang. Reach for this only if every child is guaranteed to succeed. |
Removing a job inside a flow is not independent. BullMQ cascades:
import { FlowProducer, Worker, Queue } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis({ maxRetriesPerRequest: null });
const flow = new FlowProducer({ connection });
type Img = { album: string; size: number };
// Fan-out + fan-in as a single atomic tree
await flow.add({
name: 'finalize',
queueName: 'pipeline',
data: { album: 'vacation' },
children: [200, 400, 800].map((size) => ({
name: 'resize',
data: { album: 'vacation', size } as Img,
queueName: 'images',
opts: { attempts: 3, continueParentOnFailure: true, removeOnComplete: 100 },
})),
});
// Child worker (CPU-bound — use sandboxed processors in prod)
new Worker<Img>('images', async (job) => {
return await resize(job.data); // returnvalue surfaces in getChildrenValues()
}, { connection });
// Parent worker — only fires when children resolve
new Worker('pipeline', async (job) => {
const urls = Object.values(await job.getChildrenValues());
await sendEmail({ album: job.data.album, urls });
}, { connection });
waiting-children until all children complete.FlowProducer.add() (not Queue.add) — inserts the whole tree atomically.queueName → cross-queue dependencies, arbitrary depth.repeat/debounce/deduplicate.continueParentOnFailure or failParentOnFailure.getFlow(), getChildrenValues(), getDependenciesCount(), parentKey.1. What state is a parent in while children run?
2. Which class adds a flow to the queue?
3. How does FlowProducer.add insert the tree?
4. Can a child live in a different queue?
5. If a child permanently fails, the parent does what?
6. Which option lets the parent proceed on a child fail?
7. Which option fails the parent if a child fails?
8. How does a parent read children's return values?
9. Remove a parent — what happens to children?
10. Can a flow node use the repeat option?
Or say "next" for Lesson 6: metrics, stalled jobs & queue cleanup.