For AI agents: Documentation index at /llms.txt

Skip to content

Parallel Inter-canister Calls

By default, each inter-canister call is issued and awaited in sequence. When calls are independent of one another, issuing them in parallel reduces total latency: all calls are dispatched before any response is awaited, so the round-trip times overlap instead of stacking.

Parallel calls are most beneficial when the caller and callee are on different subnets. Cross-subnet calls each take 2–3 consensus rounds; running them sequentially multiplies that cost by the number of calls. On the same subnet, calls often complete within a single round, so the gain is smaller.

Prerequisites

  • icp-cli installed
  • mops package manager with core = "2.0.0" in mops.toml

How parallel calls work

In Motoko, futures are first-class values. You can start a call by evaluating c.method() without immediately awaiting it: this sends the request message and returns an async T handle. Collecting all handles before awaiting lets all calls run concurrently.

In Rust, you can collect calls into a Vec by calling .into_future() on each Call::bounded_wait(...) expression (since Call implements IntoFuture), then pass them to futures::future::join_all, which awaits all of them together.

Parallel calls example

The following example shows a caller canister that issues n calls to a callee canister’s ping method, either sequentially or in parallel.

Sequential version: each call is awaited before the next is sent:

import Nat "mo:core/Nat";
import Error "mo:core/Error";
import Principal "mo:core/Principal";
persistent actor {
type CalleeInterface = actor { ping : () -> async () };
var callee = null : ?CalleeInterface;
public func setup_callee(c : Principal) {
callee := ?actor (Principal.toText(c) : CalleeInterface);
};
public func sequential_calls(n : Nat) : async Nat {
let c = switch callee {
case null { throw Error.reject("callee not set up") };
case (?c) { c };
};
var successful = 0;
for (_ in Nat.range(0, n)) {
try {
await c.ping(); // await each call before sending the next
successful += 1;
} catch _ {};
};
successful
};
};

Parallel version: all requests are dispatched before any response is awaited:

import List "mo:core/List";
import Nat "mo:core/Nat";
import Error "mo:core/Error";
import Principal "mo:core/Principal";
persistent actor {
type CalleeInterface = actor { ping : () -> async () };
var callee = null : ?CalleeInterface;
public func setup_callee(c : Principal) {
callee := ?actor (Principal.toText(c) : CalleeInterface);
};
// Dispatch all calls first, then collect results.
public func parallel_calls(n : Nat) : async Nat {
let c = switch callee {
case null { throw Error.reject("callee not set up") };
case (?c) { c };
};
// Evaluate c.ping() without awaiting: sends the request and returns a
// future. Collecting futures before any await dispatches all requests
// concurrently.
var futures = List.empty<async ()>();
for (_ in Nat.range(0, n)) {
try {
List.add(futures, c.ping());
} catch _ {};
};
// Await in the same order as dispatch to minimise scheduler overhead.
// The IC delivers responses in request order in practice, so in-order
// await avoids unnecessary task rescheduling.
var successful = 0;
for (f in List.values(futures)) {
try {
await f;
successful += 1;
} catch _ {};
};
successful
};
};

The full working example is available in dfinity/examples: Motoko | Rust.

In-flight call limit

The IC enforces a limit on the number of in-flight calls a canister can have outstanding to any other single canister: approximately 500 per canister pair. Dispatching more calls than this limit causes the excess to be rejected immediately. Sequential calls stay within the limit because only one call is in-flight at a time. Parallel calls can exceed it when n is large.

If calls fail due to the in-flight limit, do not retry immediately. The limit will still be full right after the failure. Instead, retry from a timer or heartbeat after a delay.

Handling partial failures

join_all and the Motoko loop both collect all outcomes, including errors. The examples above count only successes. In production, log or handle each failure:

for (f in List.values(futures)) {
try {
await f;
// handle success
} catch (e : Error.Error) {
// log or record Error.message(e)
};
};

Because each inter-canister call is a separate async boundary, a failure in one call does not roll back state changes made before or after other calls. Design for partial success: identify which calls succeeded, which failed, and whether a retry or compensation is needed.

Composite queries

A composite query is a query method that can call other query and composite query methods. Unlike update calls, composite queries are read-only, run without consensus, and complete without going through the full consensus pipeline: making them far lower latency than update-based parallel calls.

Use composite queries when all the data you need can be read from query endpoints and you do not need to modify state.

Restrictions compared to update-based parallel calls:

  • Composite queries can only be invoked directly from an agent (browser, CLI tool). They cannot be called by another canister as an update.
  • Composite queries cannot call canisters on a different subnet.
  • Composite queries cannot call update methods.

Annotations:

LanguageAnnotation
Motokocomposite query keyword on the method
Rust#[query(composite = true)]
Candidcomposite_query
import Array "mo:core/Array";
// Bucket canister: regular query
persistent actor class Bucket(n : Nat, i : Nat) {
// ...state omitted...
public query func get(k : Nat) : async ?Text {
// look up k in local state
null // placeholder
};
};
// Map canister: composite query calling into Bucket
persistent actor Map {
let n = 4;
type Bucket = actor { get : Nat -> async ?Text };
let buckets : [var ?Bucket] = Array.init<?(Bucket)>(n, null);
// Composite query: can call other query methods on other canisters
public composite query func get(k : Nat) : async ?Text {
switch (buckets[k % n]) {
case null null;
case (?bucket) await bucket.get(k); // inter-canister query call
};
};
};

The full composite query example is available in dfinity/examples: Motoko | Rust.

When to use each approach

ApproachWhen to use
Sequential update callsCalls have data dependencies; each result feeds the next call
Parallel update callsIndependent calls; latency matters; cross-subnet targets
Composite queriesRead-only data retrieval; all targets on the same subnet; lowest latency

Security considerations

Parallel and composite calls carry the same atomicity properties as any inter-canister call:

  • No atomic rollback across calls. State changes committed before the first await are persisted even if later parallel calls fail. Design state mutations to be idempotent or use a saga/compensation pattern.
  • Reentrancy. Dispatching many calls in parallel increases the window during which another ingress message can execute and observe partial state. Acquire any locks before dispatching parallel calls and release them after all calls complete.
  • Callee trust. A malicious or slow callee can delay your callback. For untrusted callees, prefer bounded_wait calls so the timeout prevents indefinite blocking. See inter-canister call security for full guidance.

Next steps