Lesson 5 — Job Flows (Parent / Child)

Goal: Model multi-step work as a tree of jobs where a parent only runs after its children finish — fan-out/fan-in pipelines like "resize 3 image sizes → upload each → then send one confirmation email."

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.

The mental model

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.

┌─ child: resize/200 ─┐ parent ────┤── child: resize/400 ─┼─► parent runs (aggregate + email) (waiting- └─ child: resize/800 ──┘ children) ← all 3 must COMPLETE

Trees can be arbitrarily deep — a child can itself have children.

The FlowProducer — your one entry point

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'

Each node carries its own queue

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.

Options on a flow node — what's NOT allowed

The opts on a flow node is a normal JobsOptions minus repeat, debounce, deduplication (and parent). So:

A flow node cannot be repeatable (no 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.

Inspecting a flow

Method / fieldReturns
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.parentKeyFully-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
});

The failure gotcha — orphans, by default

By default, a parent only runs when all children complete. A child that permanently fails (exhausts retries, Lesson 2) is failed, not completed — so the parent stays in waiting-children forever. It is orphaned. No event, no worker, no email. The pipeline silently stalls.

You must pick a failure policy. Two child-level options exist for exactly this:

Policy 1 — 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');

Policy 2 — 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
  },
  ...
],
PickWhen
continueParentOnFailureThe pipeline is useful even with partial results (best-effort email with whatever resized).
failParentOnFailureThe 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.

Removal cascades

Removing a job inside a flow is not independent. BullMQ cascades:

  1. Remove parent → all children removed too.
  2. Remove a child → its link to the parent is cut; if it was the last child, the parent completes (no worker runs it).
  3. A node that's both parent and child triggers both rules.
  4. If any affected job is currently locked (active), nothing is removed and an exception throws.

Full picture — fan-out resize, fan-in email

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 });
Tangible win. You can now express "do N things, then aggregate" as a self-contained, atomic, failure-aware pipeline — without hand-rolling a state machine of "is everyone done yet?" checks. This is the shape of most real background work: fan out, gather, finalize.

What you just learned


🧠 Quiz — 10 questions

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?


📚 Primary sources

Flows · Continue Parent · Fail Parent · Get Flow Tree (official). API: FlowProducer.

📎 Reference

Flows Cheatsheet — one-screen lookup for FlowProducer, the two failure policies, and removal cascades.

💬 Ask me next

Or say "next" for Lesson 6: metrics, stalled jobs & queue cleanup.