When state needs to flow one way
You have a configuration file that changes. A background task watches the file for modifications. When the file updates, ten other tasks need to react immediately with the new settings. Polling the file every second wastes CPU. Sending a message to ten tasks every time creates a storm of allocations. You need a way to broadcast the latest state efficiently, where listeners only wake up when something actually changes.
The tokio::sync::watch channel solves this. It provides a broadcast mechanism for the current value. Listeners wait for updates and receive the latest state instantly. History is discarded. Only the present matters.
The watch channel as a bulletin board
Think of the watch channel as a digital bulletin board. The sender writes the current state on the board. Receivers stand by and wait for the board to be updated. When the sender writes something new, all waiting receivers wake up. They read the current value. They do not receive a copy of every notice ever written. They only see what is on the board right now.
If a receiver is slow and misses an update, it wakes up and sees the latest value anyway. The channel holds exactly one value at a time. Old values are dropped immediately when a new one arrives. This design prevents backpressure. Slow receivers cannot clog the channel. The sender always writes the latest value and moves on.
Minimal example
use tokio::sync::watch;
/// Demonstrates basic watch channel usage.
#[tokio::main]
async fn main() {
// Create a channel with an initial value.
// The sender holds write authority; receivers hold read access.
let (tx, mut rx) = watch::channel("offline");
// Spawn a listener that waits for changes.
tokio::spawn(async move {
// Block until the value changes.
// Returns Ok(()) on change, Err if all senders are dropped.
while rx.changed().await.is_ok() {
// Read and advance the version atomically.
// borrow_and_update is preferred over borrow() after changed()
// to avoid race conditions and ensure consistent reads.
let status = rx.borrow_and_update();
println!("Status changed to: {}", *status);
}
});
// Send updates. Old values are dropped immediately.
// send requires T: Clone.
tx.send("connecting").unwrap();
tx.send("online").unwrap();
// Keep main alive to see output.
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
Keep the borrow scope tight. The sender cannot write while you read.
How the mechanics work
watch::channel(initial) creates a sender and a receiver. The sender holds the authority to update the value. The receiver holds a reference to the current value and a mechanism to wait for updates.
When you call tx.send(new_value), the channel replaces the old value with the new one. The old value is dropped immediately. All receivers that are waiting on changed() get notified. If multiple sends happen while receivers are asleep, the intermediate values are dropped. The receivers wake up once and see the final value.
Both the sender and receiver can be cloned. Cloning the sender gives you another handle to write to the same channel. Cloning the receiver gives you another handle to read. This allows you to pass the sender to multiple tasks that update state, or pass the receiver to multiple tasks that react. The channel lives as long as at least one sender and one receiver exist.
The receiver tracks a version number. changed() waits for the version to change. borrow_and_update() reads the value and advances the receiver's version. This ensures that each receiver processes each update exactly once. If you call changed() and then borrow(), you might read a value that was already advanced by another call. borrow_and_update() is the idiomatic way to consume a change notification.
Realistic pattern: configuration reload
Real applications often update complex state. The send_modify method lets you update the value in place without cloning. This is useful for large structs or types that do not implement Clone.
use tokio::sync::watch;
use std::time::Duration;
#[derive(Debug)]
struct Config {
max_connections: u32,
log_level: String,
}
/// Simulates a background task reloading configuration.
async fn config_watcher(tx: watch::Sender<Config>) {
loop {
// Simulate external change.
tokio::time::sleep(Duration::from_secs(5)).await;
// Update the config in place.
// send_modify takes a closure that mutates the value.
// This avoids cloning the entire struct.
tx.send_modify(|c| {
c.max_connections += 10;
c.log_level = "debug".to_string();
});
}
}
/// A worker that reacts to config changes.
async fn worker(id: u32, rx: watch::Receiver<Config>) {
loop {
// Wait for a change.
if rx.changed().await.is_ok() {
// Read the current config.
// borrow_and_update advances the version.
let config = rx.borrow_and_update();
println!(
"Worker {} updated: max_conn={}, log={}",
id, config.max_connections, config.log_level
);
} else {
// Sender dropped, exit worker.
break;
}
}
}
#[tokio::main]
async fn main() {
let initial = Config {
max_connections: 100,
log_level: "info".to_string(),
};
let (tx, rx) = watch::channel(initial);
// Spawn the watcher.
tokio::spawn(config_watcher(tx));
// Spawn multiple workers.
for id in 0..3 {
// Clone the receiver for each worker.
// This creates a new handle to the same channel.
let worker_rx = rx.clone();
tokio::spawn(worker(id, worker_rx));
}
// Keep main alive.
tokio::time::sleep(Duration::from_secs(20)).await;
}
Clone the receiver, not the sender. One sender, many receivers.
Pitfalls and compiler signals
Long borrows block the sender. rx.borrow() returns a Ref<T> guard. While the guard lives, the sender cannot update the value. If you hold the borrow across an await, you might block the sender indefinitely. Drop the borrow as soon as you are done reading. Use borrow_and_update() to read and advance in one step, then drop the result immediately.
tx.send() returns a Result. If there are no receivers, send fails with a SendError. You must handle this. If you ignore it with unwrap(), your program panics when the last receiver drops. Check the result and break out of loops if the channel is dead. A channel with no listeners is a dead letter.
tx.send() requires T: Clone. If your type does not implement Clone, the compiler rejects the code with E0277 (trait bound not satisfied). Use send_modify instead. send_modify takes a closure that mutates the value in place. It does not require Clone. Community convention prefers send_modify for complex state to avoid clone overhead, even when Clone is available.
changed() waits for a change. has_changed() checks without waiting. Use has_changed() only in non-blocking contexts. In async code, changed() is the standard. Calling has_changed() in a loop creates a busy wait. Use changed() to yield control to the executor.
Handle the SendError. A dropped receiver is a signal, not a crash.
Decision matrix
Use watch when you need to broadcast the latest value to multiple listeners and only the current state matters.
Use broadcast when listeners need to receive every message in order and can handle lag or dropped messages.
Use mpsc when you have a single producer and multiple consumers processing a queue of messages.
Use oneshot when you need a one-time signal or result from a single sender to a single receiver.
Use broadcast over watch when history matters. watch discards history; broadcast keeps a buffer of recent messages.
Use watch over broadcast when you only care about the current state and want to avoid backpressure from slow receivers.
Watch for state. Broadcast for events. Pick the tool that matches the data shape.