When using futures::future::join_all, what happens if one of the futures panics?

When a future panics inside join_all, the panic propagates immediately, terminating the join_all operation. The remaining futures may not complete, and any futures that were being polled concurrently will be dropped. Unlike try_join_all which handles Err results gracefully, panics are not caught—they bubble up and abort the entire operation. This behavior reflects Rust's panic philosophy: panics are for unrecoverable errors and should not be silently caught.

Basic Behavior of join_all

use futures::future::join_all;
 
async fn basic_join_all() {
    let futures = vec![
        async { 1 },
        async { 2 },
        async { 3 },
    ];
    
    // join_all waits for all futures and collects results
    let results: Vec<i32> = join_all(futures).await;
    println!("Results: {:?}", results);  // [1, 2, 3]
}

join_all runs all futures to completion and returns their results.

What Happens When a Future Panics

use futures::future::join_all;
use std::panic;
 
async fn panic_in_join_all() {
    let futures = vec![
        async { 1 },
        async { panic!("future panicked!") },
        async { 3 },
    ];
    
    // This will panic! The panic propagates from join_all
    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
        futures::executor::block_on(async {
            join_all(futures).await
        })
    }));
    
    match result {
        Ok(values) => println!("Completed: {:?}", values),
        Err(panic_info) => println!("Panicked: {:?}", panic_info),
    }
}
 
use std::panic::AssertUnwindSafe;

The panic immediately propagates up from join_all.

Immediate Propagation

use futures::future::join_all;
 
async fn immediate_propagation() {
    let futures = vec![
        async { 
            println!("Future 1 started");
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            println!("Future 1 completed");
            1
        },
        async { 
            println!("Future 2 started");
            panic!("Future 2 panicked!");
        },
        async { 
            println!("Future 3 started");
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            println!("Future 3 completed");
            3
        },
    ];
    
    // When future 2 panics:
    // 1. The panic propagates immediately
    // 2. Futures 1 and 3 are dropped
    // 3. Their sleep operations are cancelled
    // Output might be:
    // "Future 1 started"
    // "Future 2 started"
    // "Future 3 started"
    // Then panic occurs
}

Panics abort the entire join_all operation immediately.

join_all vs try_join_all

use futures::future::{join_all, try_join_all};
 
async fn join_vs_try_join() {
    // join_all: collects Results, panics propagate
    let futures: Vec<_> = vec![
        async { Ok::<_, String>(1) },
        async { Err("error".to_string()) },
        async { Ok(3) },
    ];
    
    // join_all completes even with Err - it's just a value
    let results: Vec<Result<i32, String>> = join_all(futures).await;
    println!("join_all results: {:?}", results);
    // [Ok(1), Err("error"), Ok(3)]
    
    // try_join_all: short-circuits on first Err (but not panic)
    let futures: Vec<_> = vec![
        async { Ok::<_, String>(1) },
        async { Err("error".to_string()) },
        async { Ok(3) },
    ];
    
    let result: Result<Vec<i32>, String> = try_join_all(futures).await;
    println!("try_join_all result: {:?}", result);
    // Err("error")
    
    // Both propagate panics - neither catches panics
}

try_join_all handles Err but not panics.

Panics vs Errors

use futures::future::join_all;
 
async fn panic_vs_error() {
    // Err is a value - join_all completes normally
    let futures: Vec<_> = vec![
        async { Ok::<i32, &str>(1) },
        async { Err("an error") },  // This is just a value
        async { Ok(3) },
    ];
    
    let results = join_all(futures).await;
    println!("With errors: {:?}", results);
    // [Ok(1), Err("an error"), Ok(3)]
    
    // Panic is not a value - join_all panics
    let futures: Vec<_> = vec![
        async { 1 },
        async { panic!("not a value") },  // This panics
        async { 3 },
    ];
    
    // This await will panic
    // let results = join_all(futures).await;
}

Errors are values that join_all collects; panics abort the operation.

Catching Panics with catch_unwind

use futures::future::join_all;
use std::panic::{catch_unwind, AssertUnwindSafe};
 
async fn catching_panics() {
    let futures = vec![
        async { 1 },
        async { panic!("panic in future!") },
        async { 3 },
    ];
    
    // catch_unwind can catch the panic
    let result = catch_unwind(AssertUnwindSafe(|| {
        futures::executor::block_on(join_all(futures))
    }));
    
    match result {
        Ok(values) => println!("Success: {:?}", values),
        Err(panic_payload) => {
            if let Some(s) = panic_payload.downcast_ref::<&str>() {
                println!("Caught panic: {}", s);
            } else if let Some(s) = panic_payload.downcast_ref::<String>() {
                println!("Caught panic: {}", s);
            } else {
                println!("Caught panic with unknown type");
            }
        }
    }
}

catch_unwind can catch panics at the boundary, but this is not idiomatic.

FuturesUnordered for More Control

use futures::stream::{FuturesUnordered, StreamExt};
 
async fn futures_unordered_control() {
    let mut futures = FuturesUnordered::new();
    futures.push(async { 1 });
    futures.push(async { panic!("panic!") });
    futures.push(async { 3 });
    
    // Process results as they complete
    // But panics still propagate during polling
    while let Some(result) = futures.next().await {
        println!("Got result: {}", result);
        // If a future panics, this loop aborts
    }
}

FuturesUnordered gives incremental results but panics still propagate.

Using AbortHandle for Cancellation

use futures::future::{join_all, AbortHandle, Abortable, Aborted};
 
async fn with_abort_handles() {
    let (abort_handle1, abort_reg1) = AbortHandle::new_pair();
    let (abort_handle2, abort_reg2) = AbortHandle::new_pair();
    
    let futures = vec![
        Abortable::new(
            async { 
                println!("Future 1 running");
                tokio::time::sleep(std::time::Duration::from_secs(1)).await;
                println!("Future 1 done");
                1
            },
            abort_reg1
        ),
        Abortable::new(
            async { 
                println!("Future 2 running");
                panic!("Future 2 panicked!");
            },
            abort_reg2
        ),
    ];
    
    // Still panics - Abortable doesn't catch panics
    // But you can abort other futures if needed
}

Abort handles don't catch panics, but allow explicit cancellation.

Panics in Tokio Context

use futures::future::join_all;
 
#[tokio::main]
async fn tokio_panics() {
    let futures = vec![
        tokio::spawn(async { 1 }),
        tokio::spawn(async { panic!("task panicked!") }),
        tokio::spawn(async { 3 }),
    ];
    
    // join_all on JoinHandles
    let results: Vec<_> = join_all(futures).await;
    
    // Results are Result<T, JoinError>
    for (i, result) in results.into_iter().enumerate() {
        match result {
            Ok(value) => println!("Task {}: {:?}", i, value),
            Err(e) => println!("Task {} failed: {}", i, e),
        }
    }
    // The panic becomes a JoinError, not a propagated panic
    // Task 0: 1
    // Task 1 failed: task was cancelled (or panic message)
    // Task 2: 3
}

Tokio tasks convert panics to JoinError, which join_all collects.

Safe Patterns for Concurrent Futures

use futures::future::join_all;
 
async fn safe_patterns() {
    // Pattern 1: Use Result for expected failures
    let futures: Vec<_> = vec![
        async { Ok::<i32, String>(1) },
        async { Err("expected failure".to_string()) },
        async { Ok(3) },
    ];
    
    let results: Vec<Result<i32, String>> = join_all(futures).await;
    let successes: Vec<i32> = results.iter()
        .filter_map(|r| r.as_ref().ok())
        .copied()
        .collect();
    
    // Pattern 2: Handle panics at task boundary (Tokio)
    let handles: Vec<_> = (0..3)
        .map(|i| tokio::spawn(async move {
            if i == 1 {
                panic!("panic in task {}", i);
            }
            i
        }))
        .collect();
    
    let results: Vec<_> = join_all(handles).await;
    for (i, result) in results.into_iter().enumerate() {
        match result {
            Ok(v) => println!("Task {} succeeded: {}", i, v),
            Err(e) => println!("Task {} failed: {}", i, e),
        }
    }
}

Use Result for expected failures and task boundaries for panic isolation.

Order of Completion with Panics

use futures::future::join_all;
 
async fn completion_order() {
    let futures = vec![
        async { 
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
            println!("Future 0 completed");
            0
        },
        async { 
            tokio::time::sleep(std::time::Duration::from_millis(25)).await;
            println!("Future 1 panicking");
            panic!("Future 1!");
        },
        async { 
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            println!("Future 2 completed");
            2
        },
    ];
    
    // Possible execution:
    // 1. All futures start
    // 2. Future 1 panics after 25ms
    // 3. Futures 0 and 2 are dropped
    // 4. "Future 1 panicking" prints
    // 5. Panic propagates
    // "Future 0 completed" may never print
    // "Future 2 completed" never prints
}

Panics interrupt the normal completion order.

Unwinding and Drop

use futures::future::join_all;
 
struct DropLogger {
    id: usize,
}
 
impl Drop for DropLogger {
    fn drop(&mut self) {
        println!("Dropping Future {}", self.id);
    }
}
 
async fn drop_on_panic() {
    let futures = vec![
        async {
            let _guard = DropLogger { id: 0 };
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            println!("Future 0 done");
        },
        async {
            let _guard = DropLogger { id: 1 };
            panic!("Future 1 panics!");
        },
        async {
            let _guard = DropLogger { id: 2 };
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            println!("Future 2 done");
        },
    ];
    
    // When future 1 panics:
    // 1. DropLogger 1 is dropped (normal scope exit)
    // 2. Futures 0 and 2 are dropped
    // 3. DropLogger 0 and 2 are dropped during unwinding
    // Output:
    // "Dropping Future 1"
    // "Dropping Future 0"
    // "Dropping Future 2"
    // Then panic propagates
}

Resources held by incomplete futures are dropped during unwinding.

Panic = Unrecoverable

use futures::future::join_all;
 
async fn panic_philosophy() {
    // Rust's design: panics are unrecoverable errors
    // 
    // join_all doesn't catch panics because:
    // 1. Panics indicate programming bugs
    // 2. Catching panics hides problems
    // 3. State may be inconsistent after panic
    // 4. The future that panicked may have left things broken
    
    // For recoverable errors, use Result:
    let futures: Vec<_> = vec![
        async { fallible_operation().await },
        async { fallible_operation().await },
    ];
    
    let results: Vec<Result<_, _>> = join_all(futures).await;
    
    // Handle errors explicitly
    for result in results {
        match result {
            Ok(value) => println!("Success: {}", value),
            Err(e) => println!("Error: {}", e),
        }
    }
}
 
async fn fallible_operation() -> Result<i32, String> {
    Ok(42)
}

Use Result for recoverable errors; let panics propagate for bugs.

Alternative: join_all with Panic Recovery

use futures::future::join_all;
use std::panic::{catch_unwind, AssertUnwindSafe};
 
async fn join_all_with_panic_recovery<T: Send + 'static>(
    futures: Vec<impl std::future::Future<Output = T> + Send + 'static>
) -> Vec<Option<T>> {
    // Wrap each future to catch panics
    let wrapped: Vec<_> = futures
        .into_iter()
        .map(|f| async {
            // This doesn't work directly - catch_unwind is not async
            // Need to use tokio::spawn or similar
            
            // For demonstration, using a spawn-based approach
            let handle = tokio::spawn(async move {
                f.await
            });
            
            handle.await.ok()
        })
        .collect();
    
    join_all(wrapped).await
}
 
// More realistic: wrap in spawned tasks
async fn safe_join_all_example() {
    let futures: Vec<_> = (0..3)
        .map(|i| async move {
            if i == 1 {
                panic!("panic in future {}", i);
            }
            i
        })
        .collect();
    
    // Spawn each future as a task
    let handles: Vec<_> = futures
        .into_iter()
        .map(|f| tokio::spawn(f))
        .collect();
    
    // join_all on handles - panics become JoinError
    let results: Vec<_> = join_all(handles).await;
    
    for (i, result) in results.into_iter().enumerate() {
        match result {
            Ok(value) => println!("Future {} returned {}", i, value),
            Err(e) => println!("Future {} panicked: {}", i, e),
        }
    }
}

Spawning futures as tasks isolates panics.

Synthesis

When a future panics inside join_all:

Immediate behavior:

  • The panic propagates immediately from the join_all().await
  • Other futures are dropped
  • Their resources are cleaned up via Drop

Key distinctions:

Situation join_all behavior
Future returns Err Collected as a value, all futures complete
Future panics Panic propagates, other futures dropped
Tokio task panics Becomes JoinError, collected as value

Best practices:

  • Use Result for expected failures, not panics
  • Spawn futures as tasks if you need panic isolation
  • try_join_all short-circuits on Err but still propagates panics
  • catch_unwind works but is not idiomatic for normal error handling

Key insight: join_all treats panics as truly exceptional—they abort the entire operation. This is intentional design: panics indicate bugs or unrecoverable states, not normal control flow. For recoverable errors, use Result return types and handle them explicitly.