BullMQ Flows for Parent-Child Jobs
Flows turn a queue into a small dependency graph, which is exactly what a fan-out-then-aggregate workload needs. This guide extends BullMQ for Node.js Ecosystems, part of Backend Frameworks & Worker Scaling.
Problem Statement
A video upload must be transcoded into four renditions, then a manifest is written that references all four. Today one job does everything serially: twelve minutes of work on one worker, no parallelism, and a failure in the last rendition repeats the first three. You want the four transcodes to run concurrently across the fleet and the manifest job to run automatically once โ and only once โ every child has succeeded.
Prerequisites
- BullMQ 4+ and Redis 6.2+ (flows use Lua scripts and sorted sets throughout).
- Distinct queues for parent and child work, so each can be scaled and tuned independently.
- Idempotent processors: a child can be retried, and the parent can be re-run after a partial failure.
- An understanding of your failure policy โ flows make it explicit, which is the point.
Step 1 โ Model the Graph
FlowProducer.add takes a tree. The parent only becomes available once every child has completed.
const { FlowProducer } = require('bullmq');
const flow = new FlowProducer({ connection });
async function submitTranscode(assetId) {
return flow.add({
name: 'write-manifest',
queueName: 'manifests',
data: { assetId },
opts: { attempts: 3, backoff: { type: 'exponential', delay: 2000 } },
children: [
{ name: 'transcode', queueName: 'transcodes', data: { assetId, rendition: '1080p' } },
{ name: 'transcode', queueName: 'transcodes', data: { assetId, rendition: '720p' } },
{ name: 'transcode', queueName: 'transcodes', data: { assetId, rendition: '480p' } },
{ name: 'transcode', queueName: 'transcodes', data: { assetId, rendition: 'audio' } },
],
});
}
Step 2 โ Read the Children's Results in the Parent
Each child's return value is stored and made available to the parent, which is what makes the aggregate step possible without a separate store.
const { Worker } = require('bullmq');
new Worker('manifests', async (job) => {
// Keys are `${queueName}:${childJobId}`; values are the children's return values.
const childValues = await job.getChildrenValues();
const renditions = Object.values(childValues);
if (renditions.length !== 4) {
// Defensive: a child that returned undefined is indistinguishable from a
// missing one, so make children always return something meaningful.
throw new Error(`expected 4 renditions, got ${renditions.length}`);
}
await writeManifest(job.data.assetId, renditions);
return { assetId: job.data.assetId, renditions: renditions.length };
}, { connection, concurrency: 4 });
// The child must return a value, or the parent sees undefined for that key.
new Worker('transcodes', async (job) => {
const { assetId, rendition } = job.data;
const key = await transcode(assetId, rendition);
return { rendition, key, bytes: await sizeOf(key) }; // ends up in the parent
}, { connection, concurrency: 2 });
Keep child return values small. They are stored in Redis and loaded together by the parent, so returning a whole payload rather than a reference recreates the sizing problem discussed in message size limits and serialization.
Step 3 โ Decide What a Failed Child Means
By default a child that exhausts its attempts leaves the parent stuck in waiting-children forever โ the flow neither completes nor fails, which is the single most confusing behaviour teams hit.
| Policy | Configuration | Use when |
|---|---|---|
| All-or-nothing | Default: parent waits; fail it explicitly on child failure | The aggregate is meaningless without every part |
| Best effort | failParentOnFailure: false and handle gaps in the parent |
Partial results are still useful |
| Fail fast | failParentOnFailure: true on the child |
A single failure invalidates the whole flow immediately |
| Ignore | ignoreDependencyOnFailure: true |
The child is optional enrichment |
children: [
{ name: 'transcode', queueName: 'transcodes', data: { assetId, rendition: '1080p' },
opts: { attempts: 3, failParentOnFailure: true } }, // required
{ name: 'thumbnail', queueName: 'thumbnails', data: { assetId },
opts: { attempts: 2, ignoreDependencyOnFailure: true } }, // nice to have
]
Choosing explicitly is the whole value here: the default "wait forever" is almost never what anyone intends, and it produces flows that sit invisibly until someone audits the waiting-children count.
Step 4 โ Retry Only What Failed
The reason to decompose in the first place is that a retry should re-run one child, not the whole graph. BullMQ retries children independently, and the parent stays waiting while that happens.
// Failed children retry on their own schedule; the parent is untouched.
new Worker('transcodes', processor, {
connection,
concurrency: 2,
settings: {
backoffStrategy: (attemptsMade) =>
Math.floor(Math.random() * Math.min(120_000, 2000 * 2 ** attemptsMade)),
},
});
To resume a flow whose child exhausted its attempts after you have fixed the cause, retry the child rather than resubmitting the flow โ resubmitting duplicates the three renditions that already succeeded.
const { Queue } = require('bullmq');
const transcodes = new Queue('transcodes', { connection });
const failed = await transcodes.getFailed(0, 100);
for (const job of failed) await job.retry(); // parent unblocks when all pass
Step 5 โ Watch the waiting-children State
const manifests = new Queue('manifests', { connection });
setInterval(async () => {
const counts = await manifests.getJobCounts('waiting-children', 'waiting', 'failed');
metrics.gauge('bullmq.waiting_children', counts['waiting-children'], { queue: 'manifests' });
}, 15_000);
A rising waiting-children count with a flat failed count is the specific signature of a child failure policy that was never configured.
Step 6 โ Know When a Flow Is the Wrong Tool
Flows model dependencies, not workflows. They handle "these must finish before that" well, and everything else poorly.
Reach for something else when you need conditional branching (run B only if A returned a particular result), loops or dynamic fan-out sized at runtime, human approval steps, or waits measured in days. Those are workflow-engine problems โ Temporal, Step Functions, or an explicit state machine in your own database โ and forcing them into a flow produces graphs nobody can reason about.
The specific smell is a flow whose shape depends on data: if the number of children or their types is computed from a child's output, the graph cannot be declared up front, and you are simulating a workflow engine with job dependencies. A cheaper intermediate step is a parent that enqueues a new flow when it completes, keeping each individual graph static and declarative while allowing the overall process to be multi-stage.
Step 7 โ Size the Fan-Out Deliberately
The number of children per flow is a capacity decision, not a modelling one. Four renditions per asset is trivial; four thousand rows per import is a different system. Two limits bite at scale.
The first is Redis. Every child carries its own job hash, its dependency entry on the parent, and its stored return value, so a flow with 5,000 children is roughly 5,000 times the bookkeeping of one โ and creating it is a single large Lua invocation that blocks Redis for the duration. Keep fan-out in the tens or low hundreds and chunk beyond that: a parent with 50 children, each of which processes 100 rows, is far kinder than one with 5,000.
The second is the parent's aggregation step. getChildrenValues loads every child's return value into memory at once, so the parent's footprint scales with fan-out multiplied by return-value size. Returning a 20KB summary from each of 500 children means a 10MB object in the parent before it does any work.
// Chunked fan-out: bounded children, bounded aggregation
const CHUNK = 100;
const chunks = chunkArray(rowIds, CHUNK); // 5,000 rows โ 50 children
await flow.add({
name: 'finalise-import', queueName: 'imports', data: { importId },
children: chunks.map((ids, i) => ({
name: 'import-chunk', queueName: 'import-chunks',
data: { importId, ids, chunk: i },
// Return a reference and a count, never the processed rows themselves.
opts: { attempts: 3 },
})),
});
The rule of thumb worth remembering: children should be sized so that one child is a meaningful unit of retry. Too fine, and the bookkeeping dominates the work; too coarse, and a retry repeats far more than it needs to.
Verification
- Children run concurrently. Wall-clock time for a flow should approximate the slowest child plus the parent, not the sum of children.
- The parent runs exactly once. Under load, assert one manifest write per asset โ a re-run indicates the parent was resubmitted rather than retried.
- A failed child blocks correctly. Force one child to exhaust its attempts and confirm the parent behaves per the configured policy rather than hanging silently.
- Retrying a child unblocks the parent. After the forced failure, retry the child and confirm the parent proceeds without re-running the successful siblings.
Gotchas & Edge Cases
getChildrenValues returns only completed children. A child that returned undefined is indistinguishable from one that is missing. Always return a value from child processors.
Flow trees are created transactionally, but not atomically with your database. If a flow must exist if and only if a row commits, apply the transactional outbox pattern.
Removing a parent orphans children. Deleting the parent job leaves children that run and complete with nothing to report to. Remove the whole tree via the flow API.
Deep trees multiply Redis round trips. Each level adds dependency bookkeeping; a three-level tree with fan-out at each level is much more expensive than the job count suggests. Prefer wide and shallow.
Child concurrency is per queue, not per flow. Ten flows each with four children put forty jobs on one queue, which will saturate a worker pool sized for four. Size the child pool for aggregate concurrency, as in configuring BullMQ concurrency limits.
Related
- BullMQ for Node.js Ecosystems โ queues, workers, and the wider BullMQ surface.
- Configuring BullMQ concurrency limits for high throughput โ sizing the pools a flow fans out into.
- Draining BullMQ workers before shutdown โ what happens to an in-flight flow during a deploy.
- Sidekiq batch jobs and workflows โ the equivalent pattern in Ruby.