Branching and Joining
- The one rule to remember
- Choosing one path:
mode="one" - Doing every step:
mode="all"+wait="all" - Doing what applies:
mode="some"+wait="all" - Letting the first one win:
wait="any" - Carrying on once enough are done:
wait="quorum" - Every combination, side by side
- Stopping everything: a terminate end inside a branch
- Splits inside splits
- What the engine does at run time
- Rules the validator enforces
- In the designer
- Coming from BPMN
- Common mistakes
A workflow is a single path until a <split> opens several, and a <join>
closes them again. This page walks through every shape the two elements make —
a choice, a fan-out, a race, a quorum, a rejection that stops everything — and
what the engine does with each at run time. The attribute-by-attribute reference
is in split and
join.
┌─ flow "mgr" ──► manager approval ──┐
start ──► split ─┤ ├──► join ──► publish
└─ flow "adm" ──► admin approval ────┘
mode="all" wait="any"
The one rule to remember
A split says how branches start. A join says when they end. Nothing is inferred.
A split carries a mode, and always says it. A join carries for (which split
it closes) and wait (what it waits for), and always says both. The engine never
guesses which join belongs to which split, never reads a node’s role off how many
arrows touch it, and never counts arrows into a join to decide when to continue.
If two things are drawn, two things are written.
Pick the shape from what you want to happen:
| I want to… | Split | Join |
|---|---|---|
take one path or another |
|
none — the paths simply meet again |
do every one of several steps |
|
|
do only the steps that apply |
|
|
let the first of several finish and drop the rest |
|
|
carry on once enough of them are done |
|
|
stop the whole run if any branch says no |
any of the above |
plus |
If you are coming from BPMN, one/some/all are the exclusive, inclusive
and parallel gateways, and wait="any" is the event-based gateway’s race. The
mapping is spelled out in Coming from BPMN, and every split-mode × policy
combination in Every combination, side by side.
|
Choosing one path: mode="one"
The everyday branch: route on the record, take exactly one way out.
<split id="route" mode="one">
<flow id="big" when="self.totalAmount > 10000" next="approve" title="Large order"/>
<flow id="blocked" when="self.customer.blocked" next="reject" title="Blocked customer"/>
<flow id="rest" default="true" next="confirm"/>
</split>
<task id="approve" role="sales.manager" next="confirm"/>
<script id="reject" next="done"><![CDATA[ self.status = 'REJECTED'; save(); ]]></script>
<script id="confirm" next="done"><![CDATA[ self.status = 'CONFIRMED'; save(); ]]></script>
<end id="done"/>
What happens:
-
The flows are tested in the order written.
bigis tested first; if it holds,blockedis never evaluated. Order is precedence, and you chose it. -
The first flow whose
whenholds is taken. If none holds, thedefaultflow is taken. -
The path that was taken runs on.
approveandrejectboth lead toconfirm— the alternatives meet again at an ordinary node. There is no join, because there is nothing to wait for: only one of them ever ran.
A one split never has a <join>. Writing one for it is refused
(join-for-invalid), because a barrier that only one branch can ever reach would
wait for nothing. Where the alternatives come back together, point them at the
same node.
|
What if nothing matches?
Without a default, a one split whose conditions all come out false fails the
run at the split. The symptom is easy to misread: the run is recorded as failed,
the trigger fires again on the record’s next update, and every node before the
split runs a second time — so what you notice is duplicated work upstream, not a
split that matched nothing.
The failure message names both fixes, and they mean different things:
-
add
<flow default="true" …/>— "if none of these apply, carry on this way"; -
put a
<wait on="condition">before the split — "the thing I am testing has not happened yet". A split decides on what the record says now; it never waits.
Give a default flow to every one split whose conditions do not provably
cover every case. The designer seeds no default for you.
|
Doing every step: mode="all" + wait="all"
Several things must all happen before the run may continue.
<split id="checks" mode="all" title="Run every check">
<flow id="credit" next="creditCheck"/>
<flow id="stock" next="stockCheck"/>
<flow id="trail" next="auditTrail"/>
</split>
<script id="creditCheck" next="merge"><![CDATA[ vars.creditOk = self.customer.rating >= 3; ]]></script>
<script id="stockCheck" next="merge"><![CDATA[ vars.stockOk = self.items.every(i => i.available); ]]></script>
<script id="auditTrail" next="merge"><![CDATA[ track(_t('Checks run')); ]]></script>
<join id="merge" for="checks" wait="all" next="persist" title="All checks done"/>
<script id="persist" next="done"><![CDATA[ save(); ]]></script>
<end id="done"/>
What happens:
-
Every flow is taken — an
allsplit evaluates no conditions, and its flows may carry neitherwhennordefault. -
The branches run one after another, in the order written, against the same record and the same
vars. "All" is about which paths are taken, not about running them at the same time. (Camunda’s default is the same; only its async continuations differ.) -
Each branch ends at
merge. When the last one arrives,mergeruns once and continues topersist.
Save once, after the join
Every branch of one run shares a single in-memory record. Let the branches
accumulate their changes and persist them once past the join, as persist does
above. Saving inside each branch writes the record once per branch: every write
runs the record’s save hooks, may re-trigger a workflow watching a field that
branch just set, and exposes half-updated states to anything reading the record
in between. One save past the join goes from the old state to the final one.
This applies to some and all. A one split runs exactly one branch, so
the save() in the node that acts is already a single write.
A branch may wait
Any branch may hold a <task> or a <wait>. When one branch parks, the
engine writes down where every branch of that split got to, and the join counts
them back in as each finishes. The run shows waiting until the last branch it is
owed reports back; the join then runs exactly once. Completing one approval never
carries the run past a barrier its siblings have not reached.
<split id="both" mode="all" title="Both approvals">
<flow id="mgr" next="manager"/>
<flow id="fin" next="finance"/>
</split>
<task id="manager" role="sales.manager" next="merge"/>
<task id="finance" role="finance.team" next="merge"/>
<join id="merge" for="both" wait="all" next="confirm"/>
Two approvals sit in two inboxes at once. Whoever answers second releases the join. A branch may park more than once before its join (two approvals in a row is an ordinary shape), and a branch waiting on a timer keeps its own wake time, so branches with different delays wake independently.
A branch may also wait on a <call> that calls once and parks: the call is
what that branch is holding, and the child finishing is what brings it back. See
call.
Doing what applies: mode="some" + wait="all"
Like all, but each flow has a condition and only the matching ones are taken.
<split id="route" mode="some" title="Whatever applies">
<flow id="large" when="self.totalAmount >= 1000" next="flagApproval" title="Large order"/>
<flow id="email" when="self.customer.email" next="noteEmail" title="Has email"/>
<flow id="none" default="true" next="merge" title="Nothing special"/>
</split>
<script id="flagApproval" next="merge"><![CDATA[ self.needsApproval = true; ]]></script>
<script id="noteEmail" next="merge"><![CDATA[ track(_t('Customer will be emailed')); ]]></script>
<join id="merge" for="route" wait="all" next="persist"/>
What happens:
-
Every condition is evaluated (unlike
one, which stops at the first hit). Every flow whosewhenholds is taken — one, two, or all of them. -
If none holds, the
defaultflow is taken instead. Here it points straight at the join, which is allowed: a flow may lead directly to its join, and it counts as one branch that arrived. -
The join waits for the branches that were actually taken, and only those. It does not care how many flows the split has on paper.
That last point is what makes some safe to use. In BPMN the inclusive join has
to work out which tokens might still be on their way; here the split knows what
it started, and the join asks the split. A some split that takes one branch
releases its join after one arrival. No look-ahead, no guessing.
Letting the first one win: wait="any"
Several branches start; the first to finish carries the run on, and the others are cancelled. This is "either approval will do", or "an answer or a deadline".
<split id="either" mode="all" title="Ask both">
<flow id="mgr" next="manager"/>
<flow id="adm" next="administrator"/>
</split>
<task id="manager" user="alice" next="whoever" title="Manager approval"/>
<task id="administrator" user="bob" next="whoever" title="Admin approval"/>
<join id="whoever" for="either" wait="any" next="note" title="Whoever answers first"/>
<script id="note" next="done"><![CDATA[ track(_t('Approved by') + ' ' + task.completedBy.name); ]]></script>
<end id="done"/>
What happens:
-
Both approvals are created; Alice and Bob each see one.
-
Alice approves. Her branch arrives at
whoever; the join is satisfied. -
Bob’s branch is cancelled: his task is withdrawn and disappears from his inbox rather than sitting there for work that is over. Had his branch been waiting on a timer, the timer would be disarmed.
-
The run continues to
note— in the same run, with the samevars, sotaskstill describes the approval that won.
A race is not only for approvals. Put a <wait on="timer"> on one flow and an
approval on another, and "approve within seven days or approve automatically"
is the same shape:
<split id="race" mode="all">
<flow id="mgr" next="manager"/>
<flow id="adm" next="administrator"/>
<flow id="auto" next="sevenDays"/>
</split>
<task id="manager" user="alice" next="decided"/>
<task id="administrator" user="bob" next="decided"/>
<wait id="sevenDays" on="timer" delay="7" unit="day" next="markAuto"/>
<script id="markAuto" next="decided"><![CDATA[ self.approvedBy = 'auto'; ]]></script>
<join id="decided" for="race" wait="any" next="confirm"/>
Whichever of the three finishes first releases decided; the other two are
cancelled, and confirm runs once.
For the single commonest case — one approval with a deadline — a
boundary on the task is simpler than a
race and stays the recommendation. Reach for wait="any" when there are
genuinely several ways to finish.
|
When a branch wins immediately
Branches run in order, so a branch that finishes without waiting can win before
the ones after it even start. In the example above, if manager were a
<script> rather than an approval, it would arrive at decided in the same
pass, the join would be satisfied on the spot, and administrator and
sevenDays would never run. Nothing of theirs is ever created — not a task, not
a timer.
That is correct behaviour for a race, and it is worth knowing when you write one: put the branch that can finish instantly last if the others should at least be started.
What a race may not contain
A <boundary cancel="false"> inside a race is refused
(boundary-cancel-invalid). Its escape branch is a separate run that no
branch owns, so cancelling a losing branch could not stop it — and "the
remaining branches are cancelled" would not be true. Use cancel="true" (the
default), move the boundary outside the race, or make the join wait="all".
Carrying on once enough are done: wait="quorum"
The same shape as any, with a threshold: "any two of the three reviewers".
<split id="board" mode="all" title="Ask three reviewers">
<flow id="r1" next="reviewOne"/>
<flow id="r2" next="reviewTwo"/>
<flow id="r3" next="reviewThree"/>
</split>
<task id="reviewOne" user="alice" next="enough"/>
<task id="reviewTwo" user="bob" next="enough"/>
<task id="reviewThree" user="carol" next="enough"/>
<join id="enough" for="board" wait="quorum" quorum="2" next="approved" title="Any two of three"/>
The second reviewer to answer releases the join; the third’s task is withdrawn.
quorum="1" is allowed and means what any means.
Use a quorum only when the branches are interchangeable. If particular
approvals are required — the manager and finance — that is wait="all" over
exactly those flows, not a count.
Only arrivals count
A quorum counts branches that arrive at the join. A branch that reaches a plain
<end> before the join is neither an arrival nor a rejection: it is simply one
fewer branch that could still arrive.
So, if a reviewer’s rejection should not count towards the quorum, route it to
an <end>:
<task id="reviewOne" user="alice" next="oneSays"/>
<split id="oneSays" mode="one">
<flow id="no" when="task.rejected" next="oneDeclined"/>
<flow id="yes" default="true" next="enough"/>
</split>
<end id="oneDeclined"/>
Now only approvals reach enough. Two rejections out of three make the quorum
impossible — and the engine says so rather than waiting for ever.
A quorum that can never be met
Two rules keep a race from waiting for something that cannot come:
-
A
quorumabove the split’s number of flows is refused when the workflow is saved (join-quorum-invalid): two flows can never produce three arrivals. -
At run time, the moment the branches still able to arrive are too few to reach the threshold — because some ended early, or a
somesplit selected fewer to begin with — the run fails, with a message naming the join, the threshold and the counts. The alternative would be a record stuckwaitingwith nothing left that could ever wake it.
If "not enough approvals" is a normal business outcome rather than an error, do
not model it as a quorum: use wait="all" and route on what the branches wrote to
the record afterwards.
Every combination, side by side
The sections above show each policy with the split it is most often paired with. A join’s behaviour depends on both — what the split selected and what the join waits for — so here is the whole grid.
| Split | wait="all" |
wait="any" |
wait="quorum" quorum="N" |
|---|---|---|---|
|
No join. One branch runs; the alternatives meet at an ordinary node. A join
naming a |
||
|
Waits for the branches whose conditions held, and only those. One match means
one arrival releases it. If nothing matched, the default flow is the only
branch: leading to the join, it is one arrival; leading to its own |
The first of the selected branches to arrive wins; the other selected ones are cancelled. Flows whose conditions were false were never started and are untouched. One match releases immediately. |
Checked before any branch runs: if fewer than |
|
Waits for every flow. Continues once when the last one arrives or ends. |
The first flow to arrive wins; every other flow is cancelled, including those not yet started. |
The |
What each policy does with the events a branch can produce:
| Event | all |
any / quorum |
|---|---|---|
A branch arrives at the join |
Counted. The join continues when nothing is left that could still arrive. |
Counted. The join continues the moment the count reaches |
A branch reaches a plain |
Not an arrival, not a problem: one fewer branch to wait for. |
Not an arrival, and it counts against you: one fewer branch that could still
reach |
Every branch ended before the join |
The join is skipped. The node after it never runs, and the run ends as
|
The run fails: the barrier could never release. The message names the join, the threshold and the counts. |
Too few branches can still arrive to reach |
Not applicable. |
The run fails at once, without waiting for the branches that are left. |
A branch fails (an error, or |
The run fails, whatever the policy. |
|
A branch reaches |
The run ends there, as |
|
A branch inside a nested split ends before its inner join |
Counted by the inner join under its own policy. If the inner join is skipped
(an |
|
Two of those rows surprise people, in opposite directions:
Under all, "everyone said no" is not a failure. Route each rejection to a plain
<end> and the join simply never runs — the run finishes, completed, with the
node after the join untouched. If a rejection should stop the process, send it
to an <end terminate="true"/>; if it should fail the run, call fail() in a
script. The engine does not decide for you which of the three "no" means.
<split id="both" mode="all">
<flow id="mgr" next="manager"/>
<flow id="fin" next="finance"/>
</split>
<task id="manager" role="sales.manager" next="managerSays"/>
<split id="managerSays" mode="one">
<flow id="no" when="task.rejected" next="managerDeclined"/> <!-- plain end: this branch is simply over -->
<flow id="yes" default="true" next="merge"/>
</split>
<end id="managerDeclined"/>
<task id="finance" role="finance.team" next="merge"/>
<join id="merge" for="both" wait="all" next="confirm"/>
Here confirm runs after finance approves, whether or not the manager did —
one arrival is enough once nothing else can come. Only if both ended early
would confirm be skipped.
Under any or quorum, an early end is a lost vote. The same rejection routed
to a plain <end> reduces what can still arrive; when the remaining branches can
no longer reach the threshold the run fails rather than waits. That is the
intended reading of a race — "one of these must finish" — and it is why the
section on quorums says to route rejections to an <end> deliberately, knowing
that two of three rejections fail the run.
Stopping everything: a terminate end inside a branch
"A rejection from either side stops the whole thing" is not a join policy — it
is an <end terminate="true"/> reached from inside a branch.
<split id="both" mode="all" title="Both approvals">
<flow id="mgr" next="manager"/>
<flow id="fin" next="finance"/>
</split>
<task id="manager" role="sales.manager" next="managerSays"/>
<split id="managerSays" mode="one">
<flow id="no" when="task.rejected" next="stop" title="Rejected"/>
<flow id="yes" default="true" next="merge" title="Approved"/>
</split>
<task id="finance" role="finance.team" next="financeSays"/>
<split id="financeSays" mode="one">
<flow id="no" when="task.rejected" next="stop" title="Rejected"/>
<flow id="yes" default="true" next="merge" title="Approved"/>
</split>
<end id="stop" terminate="true" title="Stopped"/>
<join id="merge" for="both" wait="all" next="confirm" title="Both approved"/>
Reaching stop from either branch ends the run at once: the other approval is
withdrawn, and merge never runs. Both branches may share the same terminate
end — an end does no work, so there is nothing to run twice.
The run is recorded with the status terminated rather than completed, so it
can be told apart from one that ran out of graph.
Splits inside splits
A branch may contain a split of its own. Each split has its own join, and the inner join simply carries its branch on towards the outer one:
┌─ X ─┐
┌─ A ─► inner ┤ ├─► innerJoin ─┐
outer ───┤ └─ Y ─┘ ├─► outerJoin ─► ...
└─ B ──────────────────────────────┘
<split id="outer" mode="all">
<flow id="a" next="inner"/>
<flow id="b" next="stepB"/>
</split>
<split id="inner" mode="all">
<flow id="x" next="stepX"/>
<flow id="y" next="stepY"/>
</split>
<!-- stepX, stepY lead to innerJoin; stepB leads to outerJoin -->
<join id="innerJoin" for="inner" wait="all" next="outerJoin"/>
<join id="outerJoin" for="outer" wait="all" next="done"/>
outerJoin waits for two things: branch b, and branch a — which arrives when
innerJoin has released. A branch inside the inner split may park, and waking it
resumes exactly that branch.
Two nested splits may not share one join (join-for-invalid).
A join closes one fork; if the inner branches took the outer barrier, the outer
fork would never see them arrive. Give each split its own join.
|
Two alternative splits — the two flows of a one split each leading to their
own all split — may not share a join either: each fork names its join, and a
join names one split.
What the engine does at run time
Knowing the mechanics explains most of the rules above.
- Branches run in order
-
The branches of one split are walked one after another, in the order the flows are written, within the same pass. They share the record and the same
vars. Nothing runs in parallel on separate threads. - Parking writes the split down
-
If no branch parks, the whole split — fork, branches, join — completes in one pass and leaves no trace beyond the run itself. The moment one branch parks (an approval, a timer), the engine records that activation of the split: which flows were taken, where each branch got to, and which join they are owed to. Waking one branch resumes that branch alone, and the join counts arrivals against the branches that were actually taken.
- The join runs once
-
Whether all branches arrive in one pass or over three weeks, the node after the join runs exactly once per activation. If the same split is reached again later (a loop, or a later trigger), that is a new activation with its own count.
- Cancelling a branch cancels what it holds
-
When a race is decided, or a branch fails, or a terminate end is reached, the losing branches' approvals are withdrawn from their inboxes and their timers disarmed. Deleting the record does the same for every branch of every open split on it.
- Failure is not partial
-
A branch that fails fails the run, and its siblings' approvals are withdrawn — there is no "keep the branches that were fine". Model an expected business outcome as a flow condition, not as an error.
In the run viewer (Administration → Workflow Management → Workflow Runs), a branch a race cancelled is ringed amber, a failed one red, and the panel under the graph names each state in words. See Monitoring runs.
Rules the validator enforces
Everything below is caught when the workflow is saved or loaded, with a message naming the nodes involved. Two failures are only knowable at run time and are listed at the end.
On the split
| Code | Meaning and fix |
|---|---|
|
Every split says |
|
A split with no flows leads nowhere. Add at least one. |
|
Every flow has an |
|
A flow has no |
|
On a |
|
A flow of an |
|
The default flow is the one taken when nothing matched; a condition on it would never be read. Drop one or the other. |
|
At most one default per split. |
On the join
| Code | Meaning and fix |
|---|---|
|
A join says which split it is for. |
|
|
|
|
|
A |
|
Two joins name the same split. One split, one join. |
|
A join says |
|
|
|
|
|
|
|
|
On the shape of the graph
These are the rules that keep a barrier meaningful. Each one describes a graph that would silently wait for the wrong thing, or not wait at all.
| Code | Meaning and fix |
|---|---|
|
Two branches of the same split reach the same node before their join. Concurrent branches may not share work — the node would run once per branch, or once when you meant twice. Give the shared work its own |
|
A node outside the split points into one of its branches. A path that joins a branch part way down reaches the join without the barrier the split opened, and would pass through it without waiting. Enter through the split itself, or point at a node after the join. |
|
A node outside the split points directly at its join. Same problem, same fix. |
|
An inner split and the outer split containing it name the same join. Give the inner split its own. |
|
A |
Only knowable at run time
| Failure | Meaning and fix |
|---|---|
no flow matched |
A |
quorum lost |
Too few branches can still arrive to reach |
In the designer
The Studio workflow designer draws the same two elements. Split and Join are two palette entries, each with its own required properties.
To branch a workflow:
-
Drag Split from the palette onto the canvas and connect the previous node to it.
-
In the inspector, set Mode: the first match (✕), every match (○) or every branch (+). Set it before drawing branches — switching to every branch later clears any conditions you had written.
-
Draw an edge from the split to each branch’s first node. Each edge is a flow; the designer gives it a stable id.
-
Select an arrow to edit its Condition and Title, or right-click it to Make default (or Make conditional again) and to Move earlier or Move later — the order is the order the flows are tested in. The label on the arrow is the title, or the condition when there is no title.
To close the branches (for every match and every branch):
-
Open the split’s context menu and choose Join, or drag Join from the palette. From the menu, Waits for split is filled in for you.
-
Connect every branch’s last node to the join.
-
In the inspector, set Wait: every branch, the first arrival, or enough arrivals — which reveals an Arrivals field for the count.
-
Connect the join to whatever follows.
Reading the canvas: a split’s badge is its mode (✕ ○ ); a join's badge is its policy — `` for one that waits for every branch, the count for one that continues on the first arrival or the first N. So the two halves of a race are legible without opening either inspector.
Save validates the definition with the same rules listed above; problems are reported by node. There are no designer-side rules that could drift from the engine.
Coming from BPMN
| BPMN | Here | Note |
|---|---|---|
Exclusive gateway ✕ (split) |
|
identical: first matching flow, else default |
Exclusive gateway ✕ (merge) |
nothing |
alternatives point at the same node |
Inclusive gateway ○ (split + join) |
|
the join asks the split what it started; no look-ahead |
Parallel gateway + (split + join) |
|
|
Event-based gateway |
|
over approvals, timers and conditions; there is no message model to race |
Sub-process with a terminate end, used as a race |
|
continues the same run afterwards; no sub-process needed |
Terminate end event |
|
inside a branch: stops the run, withdraws the other branches' approvals |
AND-join after an XOR-split (the classic deadlock) |
cannot be written |
a join names its split, and a |
Camunda’s join is local: a parallel join waits for one token per incoming sequence flow, and does not know which gateway produced them. That is why an AND-join downstream of an XOR-split hangs there. Here the join asks the split, so that diagram is a validation error naming both nodes, not a stuck record. On every diagram that works in Camunda the behaviour is the same; on the ones that do not, this engine is louder.
See Migrating from the BPM module for the full element map.
Common mistakes
- Expecting
allto run branches at the same time -
It does not. Branches run in order, sharing the record. Put the branch that must see the others' results last, or read them after the join.
- Saving in every branch
-
Each save is a write with hooks and triggers of its own. Save once past the join.
- Sharing a script between two branches
-
Refused as
flow-next-invalid. What looks like reuse would run the script once per branch. Put it after the join, or in a<call>each branch calls. - Giving a
onesplit a join -
Refused as
join-for-invalid. Point the alternatives at the same next node instead. - Forgetting the default flow
-
A
one/somesplit that matches nothing fails the run, and the upstream nodes run again on the next trigger. Add a default. - Reading a quorum as "N approvals"
-
A quorum counts arrivals. If a rejection must not count, route it to an
<end>before the join. - Expecting
allto fail when every branch rejects -
It does not. Early ends are not failures; with no arrivals the join is skipped and the run ends as
completed. Use a terminate end to stop, orfail()to fail — see Every combination, side by side. - Expecting
anyto survive a failing branch -
It does not. A race is decided by the first arrival; a branch that throws fails the run like anywhere else.
- Racing a non-cancelling boundary
-
Refused as
boundary-cancel-invalid. Its escape branch could not be cancelled with the branch that lost. - Pointing a later node back into a branch
-
Refused as
node-next-invalid. A loop around a whole split-and-join block is fine; a loop into the middle of a branch is not, because it would reach the join without a barrier.