{"args":{"content":"# How does futures::future::try_join differ from futures::future::join for error propagation in concurrent futures?

try_join waits for all futures to complete successfully and returns Err immediately when any future fails, while join waits for all futures regardless of success or failure and returns all results including errors. The key difference is error semantics: try_join short-circuits on the first error (similar to ? operator behavior), making it suitable when all results must succeed, while join collects all outcomes including failures, useful for cleanup or when you need to know about all failures. Use try_join when any failure should abort the operation, and join when you need to collect all results or perform cleanup regardless of individual failures.

Basic join Usage

use futures::future::join;
 
#[tokio::main]
async fn main() {
    // join waits for all futures, returns tuple of results
    let (a, b, c) = join(
        async { 1u32 },
        async { 2u32 },
        async { 3u32 },
    ).await;
    
    println!(\"a={}, b={}, c={}\", a, b, c);
    // All three values available
}

join returns a tuple with all results, waiting for every future to complete.

Basic try_join Usage

use futures::future::try_join;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // try_join returns Ok(tuple) if all succeed, Err on first failure
    let (a, b, c) = try_join(
        async { Ok::<_, Box<dyn std::error::Error>>(1u32) },
        async { Ok::<_, Box<dyn std::error::Error>>(2u32) },
        async { Ok::<_, Box<dyn std::error::Error>>(3u32) },
    ).await?;
    
    println!(\"a={}, b={}, c={}\", a, b, c);
    Ok(())
}

try_join returns Result and short-circuits on error.

join with Failures

use futures::future::join;
 
#[tokio::main]
async fn main() {
    async fn succeed() -> Result<&'static str, &'static str> {
        println!(\"succeed started\");
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        println!(\"succeed done\");
        Ok(\"success\")
    }
    
    async fn fail() -> Result<&'static str, &'static str> {
        println!(\"fail started\");
        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
        println!(\"fail done\");
        Err(\"failed\")
    }
    
    // join waits for ALL futures, even when some fail
    let (result1, result2, result3) = join(
        succeed(),
        fail(),
        succeed(),
    ).await;
    
    println!(\"Results:\");
    println!(\"  result1: {:?}\", result1);
    println!(\"  result2: {:?}\", result2);
    println!(\"  result3: {:?}\", result3);
    // All three futures completed
    // result2 is Err, but others still ran to completion
}

join waits for all futures; failures don't affect other futures' execution.

try_join Short-Circuit Behavior

use futures::future::try_join;
 
#[tokio::main]
async fn main() -> Result<(), &'static str> {
    async fn succeed() -> Result<&'static str, &'static str> {
        println!(\"succeed started\");
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        println!(\"succeed done\");
        Ok(\"success\")
    }
    
    async fn fail_fast() -> Result<&'static str, &'static str> {
        println!(\"fail started\");
        Err(\"failed immediately\")  // No delay
    }
    
    // try_join returns Err immediately when one fails
    let result = try_join(
        succeed(),
        fail_fast(),
        succeed(),
    ).await;
    
    match result {
        Ok((a, b, c)) => println!(\"All succeeded: {}, {}, {}\", a, b, c),
        Err(e) => println!(\"Error: {}\", e),
    }
    // \"succeed started\" printed but succeed futures may not complete
    // try_join returns as soon as failure is detected
    
    Ok(())
}

try_join returns immediately on error; other futures continue running but their results are discarded.

Return Type Differences

use futures::future::{join, try_join};
 
#[tokio::main]
async fn main() {
    // join returns tuple of Results
    let result: (Result<i32, &str>, Result<i32, &str>) = join(
        async { Ok::<i32, &str>(1) },
        async { Ok::<i32, &str>(2) },
    ).await;
    // Result is inside each tuple element
    
    // try_join returns Result of tuple
    let result: Result<(i32, i32), &str> = try_join(
        async { Ok::<i32, &str>(1) },
        async { Ok::<i32, &str>(2) },
    ).await;
    // Result wraps the entire tuple
    
    // join: (Result<T, E>, Result<T, E>)
    // try_join: Result<(T, T), E>
}

join returns (Result, Result); try_join returns Result<(T, T), E>.

Error Type Requirements

use futures::future::try_join;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // try_join requires all error types to be compatible
    // All futures must return Result<T, E> where E is the same type
    
    // Works: same error type
    let (a, b) = try_join(
        async { Ok::<i32, Box<dyn std::error::Error>>(1) },
        async { Ok::<i32, Box<dyn std::error::Error>>(2) },
    ).await?;
    
    // Different error types need conversion
    let (c, d) = try_join(
        async { Ok::<i32, &str>(1).map_err(|e| e.to_string()) },
        async { Ok::<i32, String>(2) },
    ).await?;
    
    println!(\"a={}, b={}, c={}, d={}\", a, b, c, d);
    Ok(())
}

try_join requires all error types to match; join doesn't care.

Cleanup Operations

use futures::future::join;
 
#[tokio::main]
async fn main() {
    async fn task_with_cleanup(id: u32) -> Result<String, String> {
        println!(\"Task {} started\", id);
        // Simulate work
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        
        // Simulate failure
        if id == 2 {
            println!(\"Task {} cleaning up after error\", id);
            return Err(format!(\"Task {} failed\", id));
        }
        
        println!(\"Task {} cleaning up after success\", id);
        Ok(format!(\"Task {} result\", id))
    }
    
    // join ensures all cleanup runs
    let results = join(
        task_with_cleanup(1),
        task_with_cleanup(2),
        task_with_cleanup(3),
    ).await;
    
    // All three tasks complete their cleanup
    // Even though task 2 failed
    for (i, result) in results.iter().enumerate() {
        println!(\"Task {}: {:?}\", i + 1, result);
    }
}

join ensures all futures complete; useful for cleanup operations.

try_join for Transactions

use futures::future::try_join;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    async fn write_database(data: &str) -> Result<u64, Box<dyn std::error::Error>> {
        println!(\"Writing to database: {}\", data);
        // Simulate database write
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        Ok(data.len() as u64)
    }
    
    async fn write_cache(data: &str) -> Result<bool, Box<dyn std::error::Error>> {
        println!(\"Writing to cache: {}\", data);
        // Simulate cache write
        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
        Ok(true)
    }
    
    async fn write_storage(data: &str) -> Result<String, Box<dyn std::error::Error>> {
        println!(\"Writing to storage: {}\", data);
        // Simulate failure
        Err(\"Storage write failed\".into())
    }
    
    // try_join fails fast on any error
    let result = try_join(
        write_database(\"data\"),
        write_cache(\"data\"),
        write_storage(\"data\"),
    ).await;
    
    match result {
        Ok(_) => println!(\"All writes succeeded\"),
        Err(e) => {
            println!(\"Transaction failed: {}\", e);
            // Note: Some writes may have completed
            // try_join doesn't roll back completed operations
        }
    }
    
    Ok(())
}

try_join short-circuits but doesn't automatically roll back completed operations.

join for Collecting All Errors

use futures::future::join;
 
#[tokio::main]
async fn main() {
    async fn validate_field(name: &str, value: &str) -> Result<(), String> {
        if value.is_empty() {
            Err(format!(\"{} is empty\", name))
        } else if value.len() < 3 {
            Err(format!(\"{} is too short\", name))
        } else {
            Ok(())
        }
    }
    
    // join all validations to see all errors
    let (name_result, email_result, age_result) = join(
        validate_field(\"name\", \"\"),
        validate_field(\"email\", \"a\"),
        validate_field(\"age\", \"valid\"),
    ).await;
    
    // Collect all errors
    let errors: Vec<String> = [
        name_result.err(),
        email_result.err(),
        age_result.err(),
    ]
    .into_iter()
    .flatten()
    .collect();
    
    for error in &errors {
        println!(\"Validation error: {}\", error);
    }
    // Shows: \"name is empty\" and \"email is too short\"
}

join lets you collect all errors; try_join would only show the first.

Concurrent Network Requests

use futures::future::try_join;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // For network requests where any failure is fatal
    async fn fetch_user(id: u32) -> Result<String, Box<dyn std::error::Error>> {
        // Simulate API call
        if id == 0 {
            Err(\"Invalid user ID\".into())
        } else {
            Ok(format!(\"User {}\", id))
        }
    }
    
    async fn fetch_posts(user_id: u32) -> Result<Vec<String>, Box<dyn std::error::Error>> {
        Ok(vec![format!(\"Post 1 by {}\", user_id)])
    }
    
    async fn fetch_comments(user_id: u32) -> Result<Vec<String>, Box<dyn std::error::Error>> {
        Ok(vec![format!(\"Comment for {}\", user_id)])
    }
    
    // try_join: if user fetch fails, don't bother with posts/comments
    let user = fetch_user(1).await?;
    let (posts, comments) = try_join(
        fetch_posts(1),
        fetch_comments(1),
    ).await?;
    
    println!(\"User: {}\", user);
    println!(\"Posts: {:?}\", posts);
    println!(\"Comments: {:?}\", comments);
    
    Ok(())
}

Use try_join when subsequent operations depend on previous success.

Parallel Processing with Partial Failures

use futures::future::join;
 
#[tokio::main]
async fn main() {
    async fn process_chunk(id: u32, data: Vec<i32>) -> Result<u32, String> {
        println!(\"Processing chunk {}\", id);
        if id == 2 {
            return Err(format!(\"Chunk {} failed\", id));
        }
        Ok(data.len() as u32)
    }
    
    let chunks: Vec<Vec<i32>> = vec![
        vec![1, 2, 3],
        vec![4, 5, 6],
        vec![7, 8, 9],
    ];
    
    // Process all chunks in parallel with join
    let results = join(
        process_chunk(1, chunks[0].clone()),
        process_chunk(2, chunks[1].clone()),
        process_chunk(3, chunks[2].clone()),
    ).await;
    
    // Check which succeeded
    let successful: Vec<u32> = [results.0.clone(), results.1.clone(), results.2.clone()]
        .iter()
        .filter_map(|r| r.as_ref().ok().copied())
        .collect();
    
    println!(\"Successful chunks: {:?}\", successful);
    
    let failed: Vec<&str> = [results.0.as_ref(), results.1.as_ref(), results.2.as_ref()]
        .iter()
        .filter_map(|r| r.as_ref().err().map(|s| *s))
        .collect();
    
    println!(\"Failed chunks: {:?}\", failed);
}

join allows partial success handling; try_join is all-or-nothing.

Performance Implications

use futures::future::{join, try_join};
use std::time::Instant;
 
#[tokio::main]
async fn main() {
    async fn slow_task(id: u32) -> Result<u32, ()> {
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        println!(\"Slow task {} completed\", id);
        Ok(id)
    }
    
    async fn fast_fail() -> Result<u32, ()> {
        println!(\"Fast fail completed\");
        Err(())
    }
    
    // join: waits for all tasks even if one fails
    let start = Instant::now();
    let _ = join(
        slow_task(1),
        fast_fail(),
        slow_task(2),
    ).await;
    println!(\"join took: {:?}\", start.elapsed());
    // All three tasks complete, ~100ms total
    
    // try_join: returns as soon as one fails
    let start = Instant::now();
    let _ = try_join(
        slow_task(1),
        fast_fail(),
        slow_task(2),
    ).await;
    println!(\"try_join took: {:?}\", start.elapsed());
    // Returns immediately after fast_fail completes
    // Other tasks continue running but results are discarded
}

try_join can return faster on failure; join always waits for all.

Combining with Other Combinators

use futures::future::{join_all, try_join_all};
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // join_all: Vec of Results
    let results: Vec<Result<i32, &str>> = join_all(vec![
        async { Ok::<i32, &str>(1) },
        async { Ok::<i32, &str>(2) },
        async { Err::<i32, &str>(\"error\") },
    ]).await;
    println!(\"join_all results: {:?}\", results);
    
    // try_join_all: Result of Vec
    let result: Result<Vec<i32>, &str> = try_join_all(vec![
        async { Ok::<i32, &str>(1) },
        async { Ok::<i32, &str>(2) },
    ]).await;
    println!(\"try_join_all result: {:?}\", result);
    
    // try_join_all fails on first error
    let result: Result<Vec<i32>, &str> = try_join_all(vec![
        async { Ok::<i32, &str>(1) },
        async { Err::<i32, &str>(\"error\") },
        async { Ok::<i32, &str>(3) }, // Not awaited
    ]).await;
    println!(\"try_join_all with error: {:?}\", result); // Err(\"error\")
    
    Ok(())
}

join_all and try_join_all work with collections of futures.

Cancellation Behavior

use futures::future::try_join;
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    async fn cancellable_task(id: u32, cancel: tokio::sync::watch::Receiver<bool>) 
        -> Result<String, Box<dyn std::error::Error>> 
    {
        println!(\"Task {} started\", id);
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        
        if *cancel.borrow() {
            println!(\"Task {} cancelled\", id);
            return Err(format!(\"Task {} cancelled\", id).into());
        }
        
        println!(\"Task {} completed\", id);
        Ok(format!(\"Result {}\", id))
    }
    
    // try_join doesn't cancel other futures when one fails
    // The other futures continue running to completion
    // Only the returned Result is affected
    
    let (tx, rx) = tokio::sync::watch::channel(false);
    
    let result = try_join(
        async { Err::<String, Box<dyn std::error::Error>>(\"immediate error\".into()) },
        async {
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
            println!(\"Second task still running\");
            Ok(\"second\".to_string())
        },
    ).await;
    
    println!(\"Result: {:?}\", result);
    // Second task still prints \"Second task still running\"
    
    Ok(())
}

try_join doesn't cancel running futures; it just returns early.

When to Use Each

use futures::future::{join, try_join};
 
// Use join when:
// - You need all results regardless of success/failure
// - Cleanup operations must complete
// - Collecting all errors for validation
// - Independent operations that should all run to completion
// - You want to handle partial success
 
// Use try_join when:
// - Any failure should abort the logical operation
// - Subsequent operations depend on all succeeding
// - Short-circuit on error is desired
// - All-or-nothing semantics required
// - Want to return early on first error
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Example: Parallel validation (need all errors)
    // Use join
    
    // Example: Parallel database writes (all must succeed)
    // Use try_join
    
    // Example: Parallel API calls where any failure is fatal
    // Use try_join
    
    // Example: Parallel cleanup operations
    // Use join
    
    // Example: Parallel downloads with fallback handling
    // Use join to see which succeeded/failed
    
    // Example: Transaction-like operations
    // Use try_join (though note: no automatic rollback)
    
    Ok(())
}

Choose based on whether partial success should be visible.

Synthesis

join characteristics:

  • Waits for all futures to complete
  • Returns tuple of individual results: (Result<T, E>, Result<T, E>, ...)
  • All futures run to completion
  • Collects all errors
  • No short-circuit behavior
  • Works with any Future type

try_join characteristics:

  • Returns on first error
  • Returns Result<(T, T, ...), E>
  • Short-circuits on error (returns Err)
  • Other futures continue running but results are discarded
  • Requires all error types to be compatible
  • All futures must return Result

Return types:

  • join(f1, f2).await → (F1::Output, F2::Output)
  • try_join(f1, f2).await → Result<(T1, T2), E>

Use join when:

  • All results needed regardless of success/failure
  • Validation: collect all errors to report
  • Cleanup: ensure all cleanup runs
  • Partial success handling: process successful results
  • Independent operations: each should complete

Use try_join when:

  • All-or-nothing semantics needed
  • First error should abort logical operation
  • Subsequent operations depend on all succeeding
  • Early return on failure is desired
  • Transaction-like patterns (without automatic rollback)

Important caveat: Neither join nor try_join cancels running futures. When try_join returns Err, the other futures continue running in the background—they're not cancelled. Their results are just discarded. If you need cancellation, use tokio::select! or explicit cancellation tokens.

Key insight: The fundamental difference is error visibility and flow control. join gives you visibility into all outcomes—you see every success and every failure. try_join gives you short-circuit semantics—fail fast and handle the first error. This mirrors the difference between collecting all validation errors (use join) versus failing on the first error (use try_join). The choice depends on whether partial success is meaningful in your domain: if you need to know about all failures or process successful results independently, use join; if any failure makes the entire operation invalid, use try_join.","path":"/articles/290_futures_try_join_vs_join.md"},"tool_call":"file.create"}