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:

FieldPurpose
terminateWhenStop the entire workflow if any expression evaluates to true
continueToRoute execution to specific downstream nodes based on expressions
pollUntilRe-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()

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 continueTo entry still run normally. Only nodes explicitly listed as continueTo targets are subject to skip logic.
flowchart LR A[check_stock] --> B[process_payment] A --> C[check_backorder] A --> D[log_metrics]

In this graph, if check_stock defines continueTo entries for process_payment and check_backorder, and only process_payment's condition is true:

  • process_payment runs (taken branch)
  • check_backorder is skipped (untaken branch)
  • log_metrics runs (independent dependent, not listed in continueTo)

Evaluation context

Condition expressions evaluate against different contexts depending on how they are written:

Expression prefixEvaluates againstExample
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:

  1. terminateWhen: All expressions are checked. If any is true, the workflow stops. continueTo is not evaluated.
  2. continueTo: All branches are evaluated. Every branch whose when is true is taken. Untaken branches and their downstream nodes are skipped (unless reachable from a taken branch).
  3. Default behavior: If no conditions are defined, or if no continueTo is 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:

  1. Execute the node's HTTP request
  2. Evaluate every pollUntil expression against the response
  3. If all expressions are true, the node completes and downstream nodes run
  4. If any expression is false, wait according to the retry policy and re-execute from step 1
  5. If the retry policy's maximumAttempts is exhausted, the node fails
flowchart LR A[Execute node] --> B{pollUntil true?} B -->|Yes| C[Continue workflow] B -->|No| D{Retries left?} D -->|Yes| E[Wait] --> A D -->|No| F[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).

FieldDescriptionExample
initialIntervalTime between the first and second attempt"5s"
backoffCoefficientMultiplier applied to the interval after each attempt1.5
maximumIntervalUpper bound on the wait time between attempts"60s"
maximumAttemptsTotal number of attempts before the node fails30

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:

  1. pollUntil is checked first. If not all conditions are true, the node retries (other conditions are not evaluated yet).
  2. Once pollUntil passes, terminateWhen is checked. If any expression is true, the workflow stops.
  3. Then continueTo is 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 terminateWhen for stopping points: cancelled orders, failed validations, or any state where continuing would be incorrect.
  • Use continueTo when you need to skip specific branches. If all dependents should run, omit continueTo entirely.
  • Order continueTo entries from most specific to most general when conditions could overlap, since all matching branches are taken.
  • Always pair pollUntil with a retry policy. Without one, the node executes once and either passes or fails immediately.
  • Set maximumAttempts and maximumInterval to 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 backoffCoefficient to reduce load on the polled service. A coefficient of 1.5 or 2 keeps 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 pollUntil with terminateWhen when 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 (true or false). Test expressions in the JSONata Playground before deploying.

Next steps