How to use tokio oneshot channel

Create a one-time message channel in Rust using tokio::sync::oneshot::channel() to send data between async tasks.

When a single handoff is all you need

You are building a web server. You need to load a configuration file, but you do not want to block the main thread while the disk grinds away. You spawn a task to read the file. That task finishes and needs to hand the config back to the main task. You do not need a continuous stream of messages. You do not need to broadcast updates to multiple listeners. You need exactly one handoff. One message. One result.

That is the job of a oneshot channel.

A oneshot channel connects two tasks with a single-use pipe. The sender puts a value in. The receiver takes it out. The channel is then destroyed. There is no second message. There is no reuse. This constraint makes the type safe, fast, and perfect for signaling completion or returning a result from a spawned task.

One message. One result. The channel vanishes.

The sealed envelope analogy

Think of a oneshot channel like a sealed envelope dropped into a secure drop box. The sender writes a message, seals it, and drops it into the slot. The receiver waits at the other end, grabs the envelope, opens it, and reads the message.

Once the receiver takes the envelope, the slot is empty and locked. You cannot drop another envelope. The receiver cannot send anything back. The mechanism is single-use by design. If the sender walks away without dropping the envelope, the receiver waits forever or times out. If the receiver leaves before the envelope arrives, the sender finds the slot locked and knows the message was never delivered.

The channel dies with the message. That is the point.

Minimal example

Create a pair with oneshot::channel(). This returns a Sender and a Receiver. The sender owns the right to write. The receiver owns the right to read. Move the sender into a spawned task. Await the receiver in the main task.

use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
    // Create the channel pair.
    // tx is the sender, rx is the receiver.
    let (tx, rx) = oneshot::channel();

    // Spawn a task that owns the sender.
    // The task moves tx into its closure.
    tokio::spawn(async move {
        let msg = "Hello from the sender";
        
        // send() takes ownership of the value.
        // It returns a Result.
        // Convention: discard the result if the receiver dropping is expected behavior.
        let _ = tx.send(msg);
    });

    // Await the message.
    // This suspends the current task until tx sends a value or drops.
    let received = rx.await.unwrap();
    println!("Got: {received}");
}

The sender moves the value. The receiver wakes. The job is done.

How the handoff happens

When you call oneshot::channel(), Tokio allocates a small shared structure on the heap. Both the sender and receiver hold a reference to this structure. The structure contains space for the value, a state flag, and a waker slot.

The sender holds the exclusive right to write. The receiver holds the exclusive right to read. When you call rx.await, the current task parks itself. Tokio registers a callback in the waker slot so the task wakes up when the sender acts.

When tx.send(value) runs, the value moves into the shared structure. The sender updates the state flag and triggers the waker. The receiver wakes up, extracts the value, and the channel is consumed. Both the sender and receiver are dropped after the transfer.

If the sender drops without sending, the state flag marks the channel as closed. The receiver wakes up and receives an error. If the receiver drops before the send, the sender detects the closed state and returns an error containing the original value.

This mechanism enables cancellation propagation. If the receiver gives up, the sender knows immediately.

Realistic pattern: decoupled computation

Oneshot channels shine when you need to decouple a heavy computation from the caller. You can spawn the work, return control to the caller, and let the caller await the result. This pattern is common in database queries, file reads, or any operation that benefits from running in the background.

use tokio::sync::oneshot;
use std::time::Duration;

async fn compute_heavy_task(input: u64) -> u64 {
    // Simulate heavy work.
    tokio::time::sleep(Duration::from_millis(100)).await;
    input * 2
}

#[tokio::main]
async fn main() {
    let (tx, rx) = oneshot::channel();

    // Spawn the work.
    // The sender is moved into the task.
    tokio::spawn(async move {
        let result = compute_heavy_task(42).await;
        
        // Send the result back.
        // If the receiver dropped, send fails and returns the value.
        let _ = tx.send(result);
    });

    // Wait for the result.
    // Handle the error case where the sender might have panicked or dropped.
    match rx.await {
        Ok(value) => println!("Result: {value}"),
        Err(_) => println!("Sender dropped without sending!"),
    }
}

Handle the error. The sender might have panicked.

Cancellation and error handling

Oneshot channels support cancellation in both directions. This is a powerful feature for building responsive systems.

If the receiver drops, the sender detects this when it tries to send. tx.send(value) returns Err(SendError(value)). The error contains the original value, allowing you to recover it or handle the cancellation gracefully.

use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
    let (tx, rx) = oneshot::channel();

    // Drop the receiver immediately.
    drop(rx);

    // The sender knows the receiver is gone.
    match tx.send("Hello") {
        Ok(()) => println!("Sent"),
        Err(e) => {
            // e.0 is the value back.
            let recovered = e.0;
            println!("Receiver dropped. Recovered value: {recovered}");
        }
    }
}

If the sender drops, the receiver detects this when it awaits. rx.await returns Err(RecvError). This happens if the task panics, returns early, or the sender is explicitly dropped.

use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
    let (tx, rx) = oneshot::channel();

    // Drop the sender immediately.
    drop(tx);

    // The receiver gets an error.
    match rx.await {
        Ok(msg) => println!("Got: {msg}"),
        Err(_) => println!("Sender dropped. No message coming."),
    }
}

You can combine oneshot channels with tokio::select! or timeout to implement cancellation from the receiver side. If the receiver times out, the sender continues running unless you explicitly handle the SendError.

use tokio::sync::oneshot;
use tokio::time::{timeout, Duration};

#[tokio::main]
async fn main() {
    let (tx, rx) = oneshot::channel();

    tokio::spawn(async move {
        // Simulate slow work.
        tokio::time::sleep(Duration::from_secs(10)).await;
        let _ = tx.send("Too slow");
    });

    // Wait with a timeout.
    match timeout(Duration::from_millis(100), rx.await).await {
        Ok(Ok(msg)) => println!("Got: {msg}"),
        Ok(Err(_)) => println!("Sender dropped"),
        Err(_) => println!("Timeout! The sender is still running."),
    }
}

Treat the Err as a cancellation signal. The receiver gave up.

Pitfalls and compiler signals

Oneshot channels are simple, but a few patterns trip up beginners.

The sender is not Clone. You can only send one message because the sender is consumed by send(). If you try to use the sender after sending, the compiler rejects you with E0382 (use of moved value). This is intentional. It prevents accidental double-sends and keeps the type safe.

use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
    let (tx, rx) = oneshot::channel();

    tx.send("First").unwrap();
    
    // Error: E0382 use of moved value: `tx`
    // tx.send("Second").unwrap();
}

The value type must implement Send. Oneshot channels are designed to cross task boundaries. If you try to send a type that is not Send, such as Rc<T> or a raw pointer, the compiler rejects you with E0277 (trait bound not satisfied). Use Arc<T> instead of Rc<T> for shared ownership across tasks.

use std::rc::Rc;
use tokio::sync::oneshot;

#[tokio::main]
async fn main() {
    let (tx, rx) = oneshot::channel();

    let data = Rc::new("Hello");
    
    // Error: E0277 the trait `Send` is not implemented for `Rc<&str>`
    // tx.send(data).unwrap();
}

Forgetting to await the receiver is a logical error, not a compile error. If you create a channel and never await rx, the sender will hang forever waiting for the receiver to read. The task will block, and your application may deadlock. Always ensure the receiver is awaited or explicitly dropped.

Convention aside: use let _ = tx.send(value) when the receiver dropping is a normal part of the flow. For example, if a user cancels a request, the receiver might drop. Ignoring the error signals that this is expected. Use tx.send(value).unwrap() only if the receiver dropping indicates a bug in your logic.

Decision: oneshot vs the rest

Rust and Tokio offer several communication primitives. Pick the one that matches your message count and ownership needs.

Use oneshot when a background task needs to return a single result to a waiting task. Use oneshot when you need to bridge synchronous code into an async context by sending a value back via a channel. Use oneshot when you want a lightweight cancellation signal where the sender detects if the receiver dropped.

Use mpsc when the producer generates a stream of messages. Use mpsc when you need multiple senders writing to a single receiver. Use mpsc when the message count is unbounded or dynamic.

Use broadcast when multiple receivers need to receive the same message. Use broadcast when you are building a pub-sub system where subscribers can join and leave.

Use a JoinHandle when the async block returns a value directly and you do not need to share the sender reference. Use JoinHandle when you want the simplest way to get a result from a spawned task.

Use Mutex when you need shared mutable state rather than message passing. Use Mutex when multiple tasks need to read and write the same data structure concurrently.

Pick the tool that matches the message count. One message gets a oneshot.

Where to go next