Flow Control
Conditions let you branch, terminate, or poll within a workflow based on node results. You define conditions on a node using JSONata expressions wrapped in {{ }} syntax.
Condition types
A node's conditions object supports three fields:
| Field | Purpose |
|---|---|
terminateWhen | Stop the entire workflow if any expression evaluates to true |
continueTo | Route execution to specific downstream nodes based on expressions |
pollUntil | Re-execute the node until all expressions evaluate to true |
All three are optional. If conditions is omitted, all dependent nodes run automatically after the node completes.
terminateWhen
terminateWhen accepts an array of JSONata boolean expressions. If any expression evaluates to true, Graph Compose stops the workflow after the current node completes. No downstream nodes run.
import { GraphCompose } from '@graph-compose/client'
const graph = new GraphCompose({
token: process.env.GRAPH_COMPOSE_TOKEN
})
graph
.node('check_order')
.get('https://api.example.com/orders/{{ context.orderId }}')
.withConditions({
terminateWhen: [
'{{ data.status = "cancelled" }}'
]
})
.end()
.node('process_order')
.post('https://api.example.com/orders/process')
.withBody({
orderId: '{{ results.check_order.data.id }}'
})
.withDependencies(['check_order'])
.end()
If the order status is "cancelled", the workflow terminates and process_order never runs.
continueTo
continueTo accepts an array of objects, each with a to (target node ID) and a when (JSONata boolean expression). Graph Compose evaluates every entry. All branches whose when condition evaluates to true are taken.
graph
.node('check_stock')
.get('https://api.example.com/stock/{{ context.productId }}')
.withConditions({
continueTo: [
{
to: 'process_payment',
when: '{{ data.quantity > 0 }}'
},
{
to: 'check_backorder',
when: '{{ data.quantity = 0 }}'
}
]
})
.end()
.node('process_payment')
.post('https://api.example.com/payments')
.withDependencies(['check_stock'])
.end()
.node('check_backorder')
.get('https://api.example.com/backorders/{{ context.productId }}')
.withDependencies(['check_stock'])
.end()
Every continueTo target node must list the source node in its dependencies array. Graph Compose validates this when the workflow is submitted.
How skipping works
When a node defines continueTo, Graph Compose evaluates all branches and divides them into two groups: taken (condition was true) and untaken (condition was false).
- Taken branches: The target nodes and their downstream dependents execute normally.
- Untaken branches: The target nodes and their downstream dependents are marked as skipped, unless they are also reachable from a taken branch.
- Independent dependents: Nodes that depend on the source node but are not listed in any
continueToentry still run normally. Only nodes explicitly listed ascontinueTotargets are subject to skip logic.
In this graph, if check_stock defines continueTo entries for process_payment and check_backorder, and only process_payment's condition is true:
process_paymentruns (taken branch)check_backorderis skipped (untaken branch)log_metricsruns (independent dependent, not listed incontinueTo)
Evaluation context
Condition expressions evaluate against different contexts depending on how they are written:
| Expression prefix | Evaluates against | Example |
|---|---|---|
Starts with results. | Full WorkflowResults object ({ context, results, row }) | {{ results.check_order.data.status = "ready" }} |
Does not start with results. | The current node's own result ({ data, statusCode, headers }) | {{ data.status = "ready" }} |
The short form ({{ data.status }}) is convenient when the condition depends only on the current node's response. Use the full form ({{ results.nodeId.data.field }}) when you need to reference another node's output.
Evaluation order
When a node completes, Graph Compose evaluates its conditions in this order:
terminateWhen: All expressions are checked. If any istrue, the workflow stops.continueTois not evaluated.continueTo: All branches are evaluated. Every branch whosewhenistrueis taken. Untaken branches and their downstream nodes are skipped (unless reachable from a taken branch).- Default behavior: If no
conditionsare defined, or if nocontinueTois present, all dependent nodes run automatically.
pollUntil
pollUntil accepts an array of JSONata boolean expressions. The node re-executes according to its retry policy until all expressions evaluate to true. Once all conditions pass, the workflow proceeds to downstream nodes.
When a node has a pollUntil condition, Graph Compose follows this cycle:
- Execute the node's HTTP request
- Evaluate every
pollUntilexpression against the response - If all expressions are
true, the node completes and downstream nodes run - If any expression is
false, wait according to the retry policy and re-execute from step 1 - If the retry policy's
maximumAttemptsis exhausted, the node fails
Configure a retry policy alongside pollUntil to control how often the node re-executes and for how long. Without a retry policy, the node executes only once. Set it at the node level in activityConfig.retryPolicy (REST) or via .withRetries() (SDK).
| Field | Description | Example |
|---|---|---|
initialInterval | Time between the first and second attempt | "5s" |
backoffCoefficient | Multiplier applied to the interval after each attempt | 1.5 |
maximumInterval | Upper bound on the wait time between attempts | "60s" |
maximumAttempts | Total number of attempts before the node fails | 30 |
With initialInterval: "5s" and backoffCoefficient: 1.5, the intervals are 5s, 7.5s, 11.25s, 16.9s, and so on, capped at maximumInterval.
Polling example
A typical polling pattern: start an asynchronous job, then poll its status endpoint until the job completes.
import { GraphCompose } from '@graph-compose/client'
const graph = new GraphCompose({
token: process.env.GRAPH_COMPOSE_TOKEN
})
graph
.node('start_job')
.post('https://api.example.com/jobs')
.withBody({
type: 'generate_report',
dataset: '{{ context.datasetId }}'
})
.end()
.node('check_status')
.get('https://api.example.com/jobs/{{ results.start_job.data.jobId }}/status')
.withDependencies(['start_job'])
.withConditions({
pollUntil: [
'{{ data.status = "complete" }}'
]
})
.withRetries({
maximumAttempts: 30,
initialInterval: '5s',
backoffCoefficient: 1.5,
maximumInterval: '60s'
})
.end()
.node('download_results')
.get('https://api.example.com/jobs/{{ results.start_job.data.jobId }}/results')
.withDependencies(['check_status'])
.end()
The check_status node polls every 5 seconds (increasing with backoff up to 60 seconds) until data.status equals "complete", or until 30 attempts are exhausted.
pollUntil accepts multiple expressions. All must evaluate to true for polling to stop:
Multiple poll conditions
graph
.node('wait_for_deploy')
.get('https://api.example.com/deploys/{{ context.deployId }}')
.withConditions({
pollUntil: [
'{{ data.status = "deployed" }}',
'{{ data.healthCheck = "passing" }}'
]
})
.withRetries({
maximumAttempts: 20,
initialInterval: '10s',
backoffCoefficient: 2,
maximumInterval: '120s'
})
.end()
Combining all three conditions
You can use pollUntil alongside terminateWhen and continueTo on the same node. Graph Compose evaluates them in order:
pollUntilis checked first. If not all conditions aretrue, the node retries (other conditions are not evaluated yet).- Once
pollUntilpasses,terminateWhenis checked. If any expression istrue, the workflow stops. - Then
continueTois evaluated. All matching branches are taken.
Poll then branch
{
id: 'check_status',
type: 'http',
http: {
method: 'GET',
url: 'https://api.example.com/jobs/{{ results.start_job.data.jobId }}/status',
},
conditions: {
pollUntil: ["{{ data.status != 'in_progress' }}"],
terminateWhen: ["{{ data.status = 'failed' }}"],
continueTo: [
{
to: 'process_results',
when: "{{ data.status = 'complete' }}",
},
{
to: 'handle_partial',
when: "{{ data.status = 'partial' }}",
},
],
},
activityConfig: {
retryPolicy: {
maximumAttempts: 30,
initialInterval: '5s',
backoffCoefficient: 1.5,
maximumInterval: '60s',
},
},
}
This node polls until the status leaves "in_progress". Once it does, if the status is "failed" the workflow terminates. If "complete", execution continues to process_results. If "partial", execution continues to handle_partial.
What happens when polling fails
If the node exhausts all retry attempts without pollUntil conditions becoming true, the node fails. What happens next depends on your workflow configuration:
- No error boundary: The workflow fails and downstream nodes do not run.
- With error boundary: The error boundary's fallback node runs, allowing you to handle the timeout gracefully (send an alert, log the failure, trigger a cleanup).
See Error Boundaries for details on protecting polling nodes.
Best practices
- Use
terminateWhenfor stopping points: cancelled orders, failed validations, or any state where continuing would be incorrect. - Use
continueTowhen you need to skip specific branches. If all dependents should run, omitcontinueToentirely. - Order
continueToentries from most specific to most general when conditions could overlap, since all matching branches are taken. - Always pair
pollUntilwith a retry policy. Without one, the node executes once and either passes or fails immediately. - Set
maximumAttemptsandmaximumIntervalto match the expected duration of the operation. Polling a payment status for 5 minutes is different from polling a data export for an hour. - Use
backoffCoefficientto reduce load on the polled service. A coefficient of1.5or2keeps early checks frequent while spacing out later ones. - Poll for a terminal state (
"complete","failed") rather than an intermediate one ("processing"). This prevents the node from completing prematurely if the API adds new intermediate states. - Combine
pollUntilwithterminateWhenwhen the polled operation can fail. Poll until the status changes, then terminate if it changed to an error state. - All condition expressions must evaluate to a boolean (
trueorfalse). Test expressions in the JSONata Playground before deploying.