Avoiding Non-Determinism in Workflows

The problem: Why determinism matters

When your workflow runs in DON mode, multiple nodes execute the same code independently. These nodes must reach consensus on the results before proceeding. If nodes execute different code paths, they generate different request IDs for capability calls, and consensus fails.

The failure pattern: Code diverges → Different request IDs → No quorum → Workflow fails

Quick reference: Common pitfalls

Don't UseUse Instead
Unordered object iterationSort keys first, then iterate
Promise.race()Call .result() in deterministic order
Date.now() or new Date()runtime.now()
LLM free-text responsesStructured output with field-level consensus

1. Object and Map iteration

JavaScript objects do not guarantee key order by specification. While modern engines preserve insertion order, relying on this behavior can cause subtle bugs, especially across different runtimes or JSON serialization.

Objects: Order is not guaranteed

Bad: Implicit iteration order

const obj = { b: 2, a: 1 }

// Order may vary across runtimes or serialization
for (const key in obj) {
  console.log(key) // Could be "b", "a" or "a", "b"
}

Good: Explicit iteration with Object.keys()

const obj = { b: 2, a: 1 }

// Preserves insertion order
for (const key of Object.keys(obj)) {
  console.log(key, obj[key]) // "b", "a" (insertion order)
}

Best: Deterministic sorted iteration

const obj = { b: 2, a: 1 }

// Guaranteed alphabetical order
for (const key of Object.keys(obj).sort()) {
  console.log(key, obj[key]) // "a", "b" (alphabetically sorted)
}

Maps and Sets: Order is guaranteed

Maps and Sets preserve insertion order by specification, making them safe for deterministic iteration.

Good: Map preserves insertion order

const map = new Map<string, number>()
map.set("b", 2)
map.set("a", 1)

for (const [key, value] of map) {
  console.log(key, value) // Always "b", then "a"
}

Good: Set preserves insertion order

const set = new Set(["b", "a"])

for (const value of set) {
  console.log(value) // Always "b", then "a"
}

2. Promise handling and the .result() pattern

SDK capabilities use the .result() pattern instead of traditional async/await. When working with multiple operations, the order in which you call .result() must be deterministic.

Avoid non-deterministic Promise methods

Bad: Promise.race() introduces non-determinism

// Different nodes may "win" the race
const fastest = await Promise.race([fetchFromAPI1(), fetchFromAPI2()])

Bad: Promise.any() picks first success

// Different nodes may succeed with different sources
const firstSuccess = await Promise.any([fetchFromAPI1(), fetchFromAPI2()])

Good: Deterministic order with .result()

import { cre, type Runtime, type NodeRuntime, consensusMedianAggregation } from "@chainlink/cre-sdk"

// Fetch from API 1, then API 2, in a fixed order
const fetchPrice = (nodeRuntime: NodeRuntime<Config>): bigint => {
  const httpClient = new cre.capabilities.HTTPClient()

  // Try first API
  const response1 = httpClient
    .sendRequest(nodeRuntime, {
      url: "https://api1.example.com/price",
    })
    .result()

  // If first API succeeds, use it; otherwise try second API
  if (response1.statusCode === 200) {
    return parsePriceFromResponse(response1)
  }

  // Try second API as fallback (deterministic order)
  const response2 = httpClient
    .sendRequest(nodeRuntime, {
      url: "https://api2.example.com/price",
    })
    .result()

  return parsePriceFromResponse(response2)
}

// In your DON mode handler
const onTrigger = (runtime: Runtime<Config>): MyResult => {
  // Run the fetch logic in node mode with consensus
  const price = runtime.runInNodeMode(fetchPrice, consensusMedianAggregation<bigint>())().result()

  return { price }
}

The key is to call .result() in a fixed, deterministic order (API 1, then API 2 if needed), not racing them.

3. Time and dates

Never use JavaScript's built-in time functions in DON mode. Nodes may have slightly different system clocks, causing divergence.

Bad: Using JavaScript's time functions

const now = Date.now() // Different on each node
const timestamp = new Date() // Different on each node

Good: Use runtime.now()

const now = runtime.now() // Same timestamp across all nodes
runtime.log(`Current time: ${now.toISOString()}`)

The runtime.now() method returns a Date object representing DON Time—a consensus-derived timestamp that all nodes agree on. See Time in CRE for more details.

4. Working with LLMs

Large Language Models (LLMs) generate different responses for the same prompt, even with temperature set to 0. This inherent non-determinism breaks consensus in workflows.

The problem: Free-text responses from LLMs will vary across nodes, making it impossible to reach agreement on the output.

The solution: Request structured output from the LLM (such as JSON with specific fields) rather than free-form text. Then use consensus aggregation on the structured fields. This approach allows nodes to agree on the key data points even if the exact text varies slightly.

Best practices summary

Do:

  • Sort object keys before iteration
  • Use Maps and Sets when insertion order matters
  • Call .result() in a fixed, deterministic order
  • Use runtime.now() for all time operations
  • Request structured output from LLMs

Don't:

  • Iterate objects with for...in without sorting keys
  • Use Promise.race(), Promise.any(), or unpredictable Promise.all() patterns
  • Use Date.now() or new Date() for timestamps
  • Rely on free-text LLM responses

Get the latest Chainlink content straight to your inbox.