Skip to content

Guide 29 of 53

Async & Concurrency

async/await, spawn and every for asynchronous and concurrent programming.

On this page

Zolo provides first-class support for asynchronous programming through async/await, and structured concurrency via spawn and every.

Async Functions

Declare an async function with the async keyword:

async fn fetch_data(url: str) -> str {
    let response = await http.get(url)
    return response.body
}

Async functions return a promise/future that can be awaited.

Await

Use await inside an async function to pause execution until a future resolves:

async fn greet_user(id: int) -> str {
    let user = await fetch_user(id)
    let profile = await fetch_profile(user.name)
    return "Hello, {user.name}! Bio: {profile.bio}"
}

await can be used on any expression that returns an async value:

async fn main() {
    let result = await greet_user(42)
    print(result)
}

Await deadlines

Use within to limit an await and receive a typed timeout result:

async fn load() -> int {
    sleep 20ms
    42
}

let outcome = await load() within 1s
match outcome {
    .Ok(value) => print(value),
    .Err(_) => print("timeout"),
}

The outcome is Result<T, std::async::TimeoutError>. A Result returned by the task remains inside Ok; its own error does not become a timeout. Ordinary task rejection or panic retains ordinary await failure behavior.

Group the deadline before applying ?: (await load() within 1s)?. The operand and interval evaluate once, in that order. The deadline starts after those evaluations and interval conversion. Use a temporal literal, an admitted temporal unit value or the standard Duration value; bare numeric values and erased types cannot supply a temporal contract. Intervals must be nonnegative. Zero allows an already completed value to succeed; a pending producer receives an immediate deadline request. Positive exact intervals round up once to the next nanosecond at the monotonic clock boundary.

On expiry, cancellation reaches the producer's suspension checkpoints, and the await waits for its cleanup and owned work before returning the timeout. Cancellation is cooperative: code that never yields cannot be interrupted, and blocking host work may take its configured timeout to finish cleanup. A pending external future needs a supported cancellation and cleanup adapter; an unavailable adapter raises an ordinary failure rather than a timeout.

Buffered fetch deadlines use the desktop VM adapter. Streaming fetch futures currently report an unavailable-adapter failure, including after headers arrive. The WebAssembly Playground runtime currently lacks the host deadline clock and reports an ordinary unavailable-clock failure.

Foreground TaskGroup ownership is separate from an await deadline. Streaming fetches can join a foreground group: the group retains the HTTP worker through response-body exit, even after the header Future settles. Cancellation shuts down the socket and drains the worker. BodyStream.close() closes an abandoned body explicitly; DNS resolution may remain pending until the system returns.

BodyStream.read_chunk() and read_lines() return validated UTF8 text and preserve characters split between packets. read_bytes() returns exact binary chunks as Future<[u8]?>. Response.bytes() and array_buffer() now return Future<[u8]>; code expecting a byte string should use a numeric byte array or an explicit validated text conversion. This applies to VM and native/LLVM.

Sequential vs Parallel Awaiting

By default, multiple await calls run sequentially:

async fn sequential() {
    let a = await task_a()   // waits for A first
    let b = await task_b()   // then waits for B
}

To run tasks in parallel, use spawn:

async fn parallel() {
    let handle_a = spawn task_a()
    let handle_b = spawn task_b()
    let a = await handle_a
    let b = await handle_b
    print("Both done: {a}, {b}")
}

Named results from parallel tasks

Use one label per task to keep differently typed results together:

let data = parallel {
    user = await load_user()
    orders = await load_orders()
}
print(data.user.name)
print(data.orders.len())

Each right-hand expression belongs to a separate task. All tasks see the enclosing scope, and the labels become fields after the tasks finish. A task cannot read another task's result through its label. Use an explicit scope and channels for communication between tasks. On failure, structured scope cancellation and cleanup complete before the failure propagates. Cancellation is cooperative; tasks must reach a channel, sleep, await or yield checkpoint.

Spawn

spawn launches a coroutine concurrently and returns a handle:

// Spawn an expression
let handle = spawn some_async_fn()

// Spawn a block
let handle2 = spawn {
    let data = fetch_data("https://api.example.com")
    process(data)
}

// Await the result later
let result = await handle
let result2 = await handle2

Fire-and-forget

You can spawn without awaiting for background tasks:

spawn log_to_server("user_logged_in")
// continues without waiting

Every

every schedules a block to run repeatedly at a given interval. The interval must be a temporal literal with a unit (s, ms, min, h); bare numbers are rejected because the unit (ms vs s) is ambiguous.

// Run every second
every 1s {
    print("tick!")
}

// Sub-second intervals use `ms`
every 500ms {
    check_health()
}

// A variable interval is fine — it carries its unit from the literal.
let interval = 500ms
every interval {
    check_health()
}

Combining with spawn

// Background heartbeat
spawn {
    every 5s {
        send_heartbeat()
    }
}

// Main work continues
let data = await fetch_data(url)
process(data)

Async Error Handling

Async functions compose naturally with Result:

use std::http
use std::Result

async fn safe_fetch(url: str) -> Result<str, str> {
    let response = await http.get(url)
    if response.status != 200 {
        return Result.Err("HTTP {response.status}")
    }
    return Result.Ok(response.body)
}

async fn main() {
    match await safe_fetch("https://api.example.com") {
        Result.Ok(body) => print("Got: {body}"),
        Result.Err(e)   => print("Error: {e}"),
    }
}

The ? operator also works in async functions:

async fn pipeline(url: str) -> Result<str, str> {
    let raw = await safe_fetch(url)?
    let parsed = parse_json(raw)?
    return Result.Ok(parsed.title)
}

Practical Pattern: Concurrent Fetch

use std::Array

async fn fetch_all(urls: [str]) -> [str] {
    // Spawn all fetches concurrently
    let handles = urls |> Array.map(|url| spawn fetch_data(url))

    // Await all results
    return handles |> Array.map(|h| await h)
}

Key Points

Concept Syntax Description
Async function async fn name() {} Returns a future
Await await expr Pauses until future resolves
Spawn expression spawn expr Run concurrently, returns handle
Spawn block spawn { ... } Run block concurrently
Repeating task every ms { ... } Execute block on interval

DOCS / FEEDBACK

Did this page leave a question?

Tell us where the explanation lost you. Documentation is part of the language experience.

Global index

Find your way through Zolo

Try an idea

Start here

9 results

9 results

en