How does futures::future::try_join differ from join for handling failures in concurrent futures?

join waits for all futures to complete regardless of success or failure, while try_join short-circuits on the first error, returning immediately when any future fails. This fundamental difference affects error handling, resource cleanup, and the shape of return types in concurrent async code.

Concurrent Future Execution

use futures::future::{join, join_all};
 
async fn concurrent_basics() {
    // Running multiple futures concurrently
    let result = join!(
        async { 1 + 2 },
        async { "hello".to_string() },
        async { vec
![1, 2, 3] },
    );
    
    // join returns a tuple of all results
    let (a, b, c) = result;
    // a = 3, b = "hello", c = vec
![1, 2, 3]
}

join runs futures concurrently and waits for all to complete, collecting all results.

The join Function

use futures::future::join;
 
async fn join_example() {
    // join waits for ALL futures to complete
    let (result1, result2) = join(
        async { Ok::<_, String>(1) },
        async { Err::<i32, String>("error".to_string()) },
    ).await;
    
    // Both results are available, even though one failed
    // result1 = Ok(1)
    // result2 = Err("error")
    
    // join always returns (T1, T2) - it doesn't short-circuit on error
}

join returns a tuple of all results, preserving both successes and failures.

The try_join Function

use futures::future::try_join;
 
async fn try_join_example() {
    // try_join short-circuits on the FIRST error
    let result = try_join(
        async { Ok::<_, String>(1) },
        async { Err::<i32, String>("error".to_string()) },
    ).await;
    
    // Result is Err because one future failed
    // result = Err("error")
    
    // The successful future's result is discarded
}

try_join returns Result<(T1, T2), E> and stops waiting on the first error.

Return Type Differences

use futures::future::{join, try_join};
 
async fn return_types() {
    // join returns a tuple of the awaited results
    // If futures return Result:
    let result: (Result<i32, String>, Result<i32, String>) = join(
        async { Ok::<_, String>(1) },
        async { Ok::<_, String>(2) },
    ).await;
    
    // try_join returns a Result containing a tuple
    let result: Result<(i32, i32), String> = try_join(
        async { Ok::<_, String>(1) },
        async { Ok::<_, String>(2) },
    ).await;
    
    // Key difference:
    // join:  (F1::Output, F2::Output)
    // try_join: Result<(T1, T2), E>
}

join wraps results in a tuple; try_join wraps the tuple in a Result.

Error Handling Behavior

use futures::future::{join, try_join};
 
async fn error_handling() {
    let future1 = async { Ok::<_, String>(42) };
    let future2 = async { Err::<i32, String>("failed".to_string()) };
    let future3 = async { Ok::<_, String>(100) };
    
    // With join: all futures complete
    let (r1, r2, r3) = join3(future1, future2, future3).await;
    // r1 = Ok(42)
    // r2 = Err("failed")
    // r3 = Ok(100)
    // All three results available
    
    // With try_join: first error wins
    let result = try_join3(future1, future2, future3).await;
    // result = Err("failed")
    // Other futures' results are lost
}

join preserves all results; try_join returns only the first error.

When to Use join

use futures::future::join;
 
async fn join_use_cases() {
    // Use join when you need ALL results, regardless of errors
    
    // Case 1: Independent operations where all results matter
    let (users, products, orders) = join(
        fetch_users(),
        fetch_products(),
        fetch_orders(),
    ).await;
    
    // Process each result independently
    match users {
        Ok(u) => println!("Users: {:?}", u),
        Err(e) => eprintln!("User error: {}", e),
    }
    // ... handle products and orders similarly
    
    // Case 2: Collecting metrics/logs from multiple sources
    let (log1, log2, log3) = join(
        fetch_log("server1"),
        fetch_log("server2"),
        fetch_log("server3"),
    ).await;
    // All logs collected, even if some fail
    
    // Case 3: Fire-and-forget cleanup operations
    let _ = join(
        cleanup_temp_files(),
        release_resources(),
        notify_shutdown(),
    ).await;
    // All cleanup attempts made
}
 
async fn fetch_users() -> Result<Vec<String>, String> { Ok(vec
!["user1"]) }
async fn fetch_products() -> Result<Vec<String>, String> { Ok(vec
!["product1"]) }
async fn fetch_orders() -> Result<Vec<String>, String> { Ok(vec
!["order1"]) }
async fn fetch_log(_server: &str) -> Result<String, String> { Ok("log".to_string()) }
async fn cleanup_temp_files() -> Result<(), String> { Ok(()) }
async fn release_resources() -> Result<(), String> { Ok(()) }
async fn notify_shutdown() -> Result<(), String> { Ok(()) }
 
fn join3<A, B, C>(a: A, b: B, c: C) -> impl std::future::Future<Output = (A::Output, B::Output, C::Output)>
where A: std::future::Future, B: std::future::Future, C: std::future::Future {
    futures::future::join(a, b, c)
}

Use join when you need to handle each result independently.

When to Use try_join

use futures::future::try_join;
 
async fn try_join_use_cases() {
    // Use try_join when ANY failure should abort the operation
    
    // Case 1: Transactional operations where all must succeed
    let result = try_join(
        debit_account(100),
        credit_account(100),
        log_transaction(),
    ).await;
    
    match result {
        Ok((debit_result, credit_result, log_result)) => {
            println!("Transaction complete");
        }
        Err(e) => {
            eprintln!("Transaction failed: {}", e);
            // First error stops waiting
        }
    }
    
    // Case 2: Required parallel validations
    let result = try_join(
        validate_input(),
        check_permissions(),
        verify_rate_limit(),
    ).await;
    
    if let Ok((_, _, _)) = result {
        // All validations passed
    }
    
    // Case 3: Data that must all be present
    let result = try_join(
        fetch_config(),
        fetch_secrets(),
        fetch_database_url(),
    ).await;
}
 
async fn debit_account(_amount: i32) -> Result<(), String> { Ok(()) }
async fn credit_account(_amount: i32) -> Result<(), String> { Ok(()) }
async fn log_transaction() -> Result<(), String> { Ok(()) }
async fn validate_input() -> Result<(), String> { Ok(()) }
async fn check_permissions() -> Result<(), String> { Ok(()) }
async fn verify_rate_limit() -> Result<(), String> { Ok(()) }
async fn fetch_config() -> Result<String, String> { Ok("config".to_string()) }
async fn fetch_secrets() -> Result<String, String> { Ok("secrets".to_string()) }
async fn fetch_database_url() -> Result<String, String> { Ok("url".to_string()) }

Use try_join when the entire operation should fail on any error.

Short-Circuit Timing

use futures::future::try_join;
use std::time::{Duration, Instant};
 
async fn short_circuit_timing() {
    let fast_error = async {
        tokio::time::sleep(Duration::from_millis(10)).await;
        Err::<i32, String>("fast error")
    };
    
    let slow_success = async {
        tokio::time::sleep(Duration::from_millis(1000)).await;
        Ok::<_, String>(42)
    };
    
    let start = Instant::now();
    let result = try_join(fast_error, slow_success).await;
    let elapsed = start.elapsed();
    
    // result = Err("fast error")
    // elapsed β‰ˆ 10ms (NOT 1000ms)
    // try_join returns as soon as the first error is detected
    // slow_success continues running but its result is ignored
}

try_join returns immediately on error, potentially saving time.

Cancellation Behavior

use futures::future::{join, try_join};
 
async fn cancellation() {
    // With join: all futures run to completion
    // No future is cancelled
    
    // With try_join: when one future fails:
    // - The failed future's result is returned
    // - Other futures continue running but their results are ignored
    // - The futures are NOT automatically cancelled
    
    // Important: try_join doesn't cancel other futures
    // They continue running until completion or dropped
    
    let result = try_join(
        async { Err::<i32, String>("error") },
        async {
            // This still runs even after try_join returns
            tokio::time::sleep(Duration::from_secs(10)).await;
            Ok::<_, String>(42)
        },
    ).await;
    
    // The second future is still sleeping in the background
}

try_join doesn't cancel other futures; they continue running.

Error Type Requirements

use futures::future::try_join;
 
async fn error_type_requirements() {
    // try_join requires all errors to be the same type
    // Or implement From for conversion
    
    // Same error type - works directly
    let result = try_join(
        async { Ok::<i32, String>(1) },
        async { Ok::<i32, String>(2) },
    ).await;
    
    // Different error types - need conversion
    // This would NOT compile:
    // let result = try_join(
    //     async { Ok::<i32, String>(1) },
    //     async { Ok::<i32, i32>(2) },  // Different error type
    // ).await;
    
    // Solution: Use Box<dyn Error> or an enum
    #[derive(Debug)]
    enum MyError {
        Io(String),
        Parse(String),
    }
    
    let result = try_join(
        async { Ok::<i32, MyError>(1) },
        async { Ok::<i32, MyError>(2) },
    ).await;
}

try_join requires compatible error types across all futures.

Join Variants

use futures::future::{join, join3, join_all, try_join, try_join_all};
 
async fn join_variants() {
    // join: 2 futures
    let (a, b) = join(async { 1 }, async { 2 }).await;
    
    // join3, join4, join5: for 3-5 futures
    let (a, b, c) = join3(async { 1 }, async { 2 }, async { 3 }).await;
    
    // join_all: for collections of futures
    let results: Vec<i32> = join_all(vec
![async { 1 }, async { 2 }, async { 3 }]).await;
    
    // try_join: 2 futures, returns Result
    let result: Result<(i32, i32), String> = try_join(
        async { Ok::<_, String>(1) },
        async { Ok::<_, String>(2) },
    ).await;
    
    // try_join_all: for collections, returns Result<Vec<T>, E>
    let result: Result<Vec<i32>, String> = try_join_all(vec
![
        async { Ok::<_, String>(1) },
        async { Ok::<_, String>(2) },
    ]).await;
}
 
fn join3<A, B, C>(a: A, b: B, c: C) -> impl std::future::Future<Output = (A::Output, B::Output, C::Output)>
where A: std::future::Future, B: std::future::Future, C: std::future::Future {
    futures::future::join3(a, b, c)
}

The futures crate provides variants for different numbers of futures.

Comparison Table

use futures::future::{join, try_join};
 
async fn comparison() {
    // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    // β”‚ Aspect              β”‚ join                      β”‚ try_join              β”‚
    // β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
    // β”‚ Return type         β”‚ (T1, T2)                  β”‚ Result<(T1, T2), E>   β”‚
    // β”‚ Error handling      β”‚ All futures complete      β”‚ First error short-circuitsβ”‚
    // β”‚ Results available   β”‚ All results preserved     β”‚ Only first error      β”‚
    // β”‚ Timing              β”‚ Waits for all             β”‚ Returns on first errorβ”‚
    // β”‚ Cancellation        β”‚ No cancellation           β”‚ No auto-cancellation  β”‚
    // β”‚ Error type          β”‚ Can be different         β”‚ Must be compatible     β”‚
    // β”‚ Use case            β”‚ Independent operations    β”‚ Transactional operationsβ”‚
    // β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
}

Complete Example

use futures::future::{join, try_join, try_join_all};
 
async fn fetch_user(id: u32) -> Result<String, String> {
    // Simulate API call
    Ok(format!("User {}", id))
}
 
async fn fetch_posts(user_id: u32) -> Result<Vec<String>, String> {
    // Simulate API call
    Ok(vec
![format!("Post by user {}", user_id)])
}
 
async fn fetch_comments(user_id: u32) -> Result<Vec<String>, String> {
    // Simulate API call that might fail
    if user_id == 0 {
        Err("Invalid user".to_string())
    } else {
        Ok(vec
![format!("Comment on user {}", user_id)])
    }
}
 
async fn main_example() {
    // Example 1: Using join for independent operations
    let user_id = 1;
    
    let (user, posts, comments) = join(
        fetch_user(user_id),
        fetch_posts(user_id),
        fetch_comments(user_id),
    ).await;
    
    // Handle each result independently
    if let Ok(user) = user {
        println!("User: {}", user);
    }
    if let Ok(posts) = posts {
        println!("Posts: {:?}", posts);
    }
    if let Ok(comments) = comments {
        println!("Comments: {:?}", comments);
    }
    
    // Example 2: Using try_join for transactional operations
    let result = try_join(
        fetch_user(user_id),
        fetch_posts(user_id),
        fetch_comments(user_id),
    ).await;
    
    match result {
        Ok((user, posts, comments)) => {
            println!("All succeeded: {}, {:?}, {:?}", user, posts, comments);
        }
        Err(e) => {
            println!("Something failed: {}", e);
        }
    }
    
    // Example 3: try_join with error (demonstrates short-circuit)
    let result = try_join(
        fetch_user(0),      // Would fail if id 0 was invalid
        fetch_posts(0),
        fetch_comments(0),  // This fails
    ).await;
    
    assert!(result.is_err());
}

Summary

use futures::future::{join, try_join};
 
async fn summary() {
    // β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    // β”‚ Behavior            β”‚ join                      β”‚ try_join              β”‚
    // β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
    // β”‚ Completion          β”‚ Waits for all             β”‚ Returns on first errorβ”‚
    // β”‚ Return shape        β”‚ Tuple of results          β”‚ Result of tuple       β”‚
    // β”‚ Failed futures      β”‚ Result included in tuple  β”‚ First error returned  β”‚
    // β”‚ Successful futures  β”‚ Result included in tuple  β”‚ Discarded on error    β”‚
    // β”‚ Error types         β”‚ Can differ per future     β”‚ Must be compatible    β”‚
    // β”‚ Primary use case    β”‚ Independent operations    β”‚ All-or-nothing operationsβ”‚
    // β”‚ Best when           β”‚ You need all results      β”‚ Any failure aborts    β”‚
    // β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    
    // Key points:
    // 1. join returns a tuple with all results, successful or not
    // 2. try_join returns Result<(T1, T2), E>, short-circuiting on error
    // 3. Neither automatically cancels running futures
    // 4. try_join requires compatible error types
    // 5. Use join for independent operations, try_join for transactions
}

Key insight: The choice between join and try_join reflects a fundamental design decision about error semantics. join treats futures as independent operations where each result matters individually. try_join treats futures as a unit where success is all-or-nothing. Use join when you want to collect all outcomes (like gathering metrics from multiple services), and try_join when any failure invalidates the entire operation (like a database transaction or multi-step validation).