Select & Join

Race two async operations and take the first result, or wait for all of them to complete. A fundamental pattern in any concurrent system.

Hew

select and join are expressions that return values directly. Timeout is an after clause inside the select, not a separate timer. No channels or runtime setup required.

select_join.hew
// Race two sources — first response wins
let winner = select {
    x from await primary.fetch(key) => x,
    y from await fallback.fetch(key) => y,
    after 100ms => Err("timeout"),
};
// Wait for both to complete — destructure results
let (users, posts) = join {
    await db.get_users(),
    await db.get_posts(),
};

Go

Go's select is a statement that operates on channels. You need to wire up goroutines and channels before the select, and use separate variables for results. The join pattern requires result channels or a sync.WaitGroup with shared variables.

select_join.go
// Race: wire up channels and goroutines first
ch1 := make(chan Result, 1)
ch2 := make(chan Result, 1)
go func() { ch1 <- primary.Fetch(key) }()
go func() { ch2 <- fallback.Fetch(key) }()

var winner Result
select {
case x := <-ch1:
    winner = x
case y := <-ch2:
    winner = y
case <-time.After(100 * time.Millisecond):
    winner = Result{Err: "timeout"}
}

// Join: buffered channels collect results
usersCh := make(chan []User, 1)
postsCh := make(chan []Post, 1)
go func() { usersCh <- db.GetUsers() }()
go func() { postsCh <- db.GetPosts() }()
users := <-usersCh // blocks until ready
posts := <-postsCh

Rust (tokio)

Tokio's select! and join! macros are close to Hew's ergonomics. The main differences: they require a tokio runtime, select! is a macro (not a language construct), and the borrow checker can fight you on shared state in select branches.

select_join.rs
// Race: tokio::select! macro
let winner = tokio::select! {
    x = primary.fetch(key) => x,
    y = fallback.fetch(key) => y,
    _ = tokio::time::sleep(
        Duration::from_millis(100)
    ) => Err("timeout"),
};

// Join: tokio::join! macro
let (users, posts) = tokio::join!(
    db.get_users(),
    db.get_posts(),
);

What this shows

Developer experience

Hew's select and join are expressions — assign the result directly to let. Go's select is a statement that requires pre-wired channels, separate goroutines, and result variables declared outside the select block. Rust's tokio macros are comparable in ergonomics but require a runtime dependency.

Debugging

In Hew, the timeout is visible in the select expression as an after clause. In Go (before 1.23), time.After allocates a timer that isn't freed until it fires — in a tight loop, this leaks resources. Even in modern Go, the losing goroutine continues running unless explicitly cancelled via a context.

Trade-offs

Go's channels are more general-purpose — they work for any goroutine-to-goroutine communication, not just request/response patterns. Rust's tokio macros handle arbitrary futures, not just actor messages. Hew's select and join are narrower in scope (actor message awaits) but eliminate the plumbing code entirely.