# Fire-and-Forget vs Synchronous Function Invocations in the iii Protocol

> Explore fire-and-forget vs synchronous function invocations in the iii protocol. Understand how iii handles return paths and invocation IDs for efficient communication and data retrieval.

- Repository: [iii/iii](https://github.com/iii-hq/iii)
- Tags: deep-dive
- Published: 2026-05-28

---

**Fire-and-forget invocations in the iii protocol suppress the return path by omitting the `invocation_id` and setting `action` to `TriggerAction::Void`, while synchronous calls generate a UUID, wait for `Message::InvocationResult`, and return the deserialized JSON value.**

The **iii** protocol, maintained in the `iii-hq/iii` repository, supports multiple invocation patterns for remote functions. Understanding the distinction between fire-and-forget and synchronous execution is critical for building responsive applications that balance latency guarantees against reliability requirements.

## Core Differences Between Invocation Patterns

The iii engine routes every function call through a single WebSocket message: `Message::InvokeFunction`. This message contains an optional `invocation_id` and an optional `action` field. The presence or absence of these fields determines whether the call executes synchronously or as fire-and-forget.

### Synchronous Invocation Flow

In the default synchronous mode, the SDK generates a unique `invocation_id` (UUID) and sends it with the `InvokeFunction` message. According to the engine implementation in [`engine/src/engine/mod.rs`](https://github.com/iii-hq/iii/blob/main/engine/src/engine/mod.rs), the engine executes the function and builds a `Message::InvocationResult` that routes back to the caller using the same correlation ID.

The SDK waits up to `timeout_ms` for this result and returns the deserialized JSON value. If the function throws an error or times out, the SDK receives the error details in the `InvocationResult` payload and propagates it as a `Result::Err`.

### Fire-and-Forget Invocation Flow

For fire-and-forget, the SDK sets `action` to `Some(TriggerAction::Void)` and deliberately omits the `invocation_id` (sends `None`). The engine still invokes the function via `spawn_invoke_function`, but because there is no ID to correlate a response, it never sends an `InvocationResult` back to the caller.

The SDK immediately resolves with `Value::Null` (JSON `null`) without waiting for function completion. Consequently, the caller cannot know if the function succeeded, failed, or timed out.

### Async Queue (Enqueue) Pattern

A third option uses `TriggerAction::Enqueue { queue }`, which still transmits an `invocation_id` for acknowledgment but places the job on a named queue. The engine returns a receipt object containing `messageReceiptId` rather than the function result. This differs from fire-and-forget because the caller receives confirmation that the message entered the queue, whereas fire-and-forget provides zero acknowledgment.

## Implementation Details in the iii Engine and SDKs

### SDK Side: The `trigger` Method

In [`sdk/packages/rust/iii/src/iii.rs`](https://github.com/iii-hq/iii/blob/main/sdk/packages/rust/iii/src/iii.rs) (lines 1010-1066), the `III::trigger` method inspects the `action` field of the `TriggerRequest`. If the action matches `TriggerAction::Void`, the client sends `InvokeFunction` with `invocation_id = None` and returns `Value::Null` immediately. For synchronous calls, it generates the UUID, registers a pending promise, awaits the correlated `InvocationResult`, and returns the payload.

### Engine Side: Message Routing

In [`engine/src/engine/mod.rs`](https://github.com/iii-hq/iii/blob/main/engine/src/engine/mod.rs) (lines 1087-1099), the `Message::InvokeFunction` handler matches on the `action` field. When the action is `Void`, the engine calls `spawn_invoke_function` with `None` for the invocation ID parameter, thereby suppressing any response message back to the caller. The function executes in a spawned task, but the engine discards the result rather than serializing it back to the WebSocket client.

### Protocol Definitions

`TriggerAction::Void` is defined in [`sdk/packages/rust/iii/src/protocol.rs`](https://github.com/iii-hq/iii/blob/main/sdk/packages/rust/iii/src/protocol.rs) and mirrored in [`engine/src/protocol.rs`](https://github.com/iii-hq/iii/blob/main/engine/src/protocol.rs) as the explicit fire-and-forget routing option. This enum variant signals to both SDK and engine that no return path should be established.

## Code Examples

### Rust SDK: Fire-and-Forget vs Synchronous

```rust
use iii_sdk::{III, TriggerRequest, TriggerAction};
use serde_json::json;

// Synchronous call – waits for result and errors
let result = iii.trigger(TriggerRequest {
    function_id: "compute_sum".into(),
    payload: json!({ "x": 10, "y": 20 }),
    action: None, // Default synchronous behavior
    timeout_ms: Some(5000),
}).await?; // Returns JSON value or error

// Fire-and-forget – returns null immediately, function executes asynchronously
iii.trigger(TriggerRequest {
    function_id: "log_metrics".into(),
    payload: json!({ "cpu": 45.2, "memory": 1024 }),
    action: Some(TriggerAction::Void),
    timeout_ms: None,
}).await?; // Always resolves to Value::Null

```

### Node.js SDK: Fire-and-Forget

```typescript
import { iii, TriggerAction } from '@iii/sdk';

// Register a function that will receive the fire-and-forget call
iii.registerFunction('analytics.track', async (data) => {
  console.log('Tracking event:', data);
  // Return value is discarded; caller receives null
  return { recorded: true };
});

// Fire-and-forget invocation – returns immediately
iii.trigger({
  function_id: 'analytics.track',
  payload: { event: 'purchase', amount: 99.99 },
  action: TriggerAction.Void(),
});
// Execution continues without awaiting function completion

```

The test file [`sdk/packages/node/iii/tests/bridge.test.ts`](https://github.com/iii-hq/iii/blob/main/sdk/packages/node/iii/tests/bridge.test.ts) (lines 42-71) validates this behavior by asserting that `TriggerAction.Void()` resolves instantly while the registered function processes the payload in the background.

### Async Queue Pattern for Comparison

```rust
let receipt = iii.trigger(TriggerRequest {
    function_id: "iii::durable::publish".into(),
    payload: json!({ "topic": "orders", "data": { "id": 123 } }),
    action: Some(TriggerAction::Enqueue { queue: "orders".into() }),
    timeout_ms: None,
}).await?; // Returns { "messageReceiptId": "uuid-here" }

```

## When to Use Each Pattern

### When to Use Fire-and-Forget

Use fire-and-forget when you need **low latency** and do not require confirmation of success. Ideal scenarios include:

- Emitting metrics or telemetry data
- Logging events to external systems
- Sending notifications where delivery confirmation is unnecessary
- Triggering side effects that must not block the caller

Because the iii protocol provides no error propagation for fire-and-forget, ensure the function handles errors internally via logging or separate error-tracking triggers.

### When to Use Synchronous Calls

Choose synchronous invocations when the caller depends on the function output or must react to failure:

- Data retrieval or validation operations
- Transactional workflows requiring confirmation before proceeding
- Sequential logic where step B requires the result of step A
- User-facing operations where errors must propagate to the UI

## Summary

- **Fire-and-forget** sends `Message::InvokeFunction` without an `invocation_id` and with `action: TriggerAction::Void`, resulting in immediate `null` return and no error propagation to the caller.
- **Synchronous** invocations generate a UUID for `invocation_id`, await the corresponding `Message::InvocationResult`, and return the full JSON response or error details.
- The distinction is enforced in [`sdk/packages/rust/iii/src/iii.rs`](https://github.com/iii-hq/iii/blob/main/sdk/packages/rust/iii/src/iii.rs) (SDK logic) and [`engine/src/engine/mod.rs`](https://github.com/iii-hq/iii/blob/main/engine/src/engine/mod.rs) (engine routing).
- Use fire-and-forget for high-throughput, non-critical side effects; use synchronous calls when data integrity or error handling is required.

## Frequently Asked Questions

### What happens if a fire-and-forget function fails in the iii protocol?

The caller never receives the error. Because fire-and-forget omits the `invocation_id`, the engine cannot route an `InvocationResult` back to the SDK. Errors must be handled internally within the function using try-catch blocks, internal logging, or external observability services.

### Can I convert a synchronous call to fire-and-forget without modifying the function code?

Yes. The caller controls the invocation pattern via the `action` field in the `TriggerRequest`. The same function can be invoked synchronously by one client and as fire-and-forget by another, depending solely on whether `action` is set to `None` or `Some(TriggerAction::Void)`.

### Is there a network latency penalty for synchronous calls compared to fire-and-forget?

Synchronous calls incur higher latency because the SDK waits for the function to execute plus the round-trip time for the `InvocationResult` message. Fire-and-forget reduces network chatter by eliminating the response message, making it suitable for high-frequency operations where minimal latency is prioritized over delivery confirmation.

### How does the async queue option differ from fire-and-forget in iii?

Unlike fire-and-forget, the enqueue pattern (`TriggerAction::Enqueue`) still transmits an `invocation_id` and returns a `messageReceiptId` to confirm the job entered the queue. Fire-and-forget provides no acknowledgment whatsoever, while enqueue provides confirmation of queue entry but defers execution to a background worker process.