When the producer outpaces the consumer
You are building a chat server. A user starts spamming messages. Your consumer task is saving each message to a database, which takes 50 milliseconds per write. The producer reads from the socket and pushes messages into a queue. The queue grows. 10,000 messages. 100,000 messages. The process consumes gigabytes of RAM. The operating system kills the process.
This is the backpressure problem. The producer generates data faster than the consumer can process it. Without a mechanism to slow the producer down, buffers fill until memory runs out. Backpressure is the signal that travels upstream when the downstream is full. It tells the producer to wait, drop work, or reject new requests. In Rust, you don't hope the buffer is big enough. You build the pressure valve into the types and the runtime.
Backpressure is a flow control valve
Think of a bucket brigade. People stand in a line passing buckets of water. If the person at the end dumps buckets slowly, the line must slow down. If the person at the front keeps handing buckets faster than they can be passed, buckets pile up on the ground and get lost. Backpressure is the rule that says: if you have a bucket in your hands, you cannot take another one. You must wait until your hands are empty.
In async Rust, this rule is enforced by the scheduler and the types. When a task tries to send data into a full buffer, the task doesn't spin the CPU. It yields control. The runtime parks the task. The task only wakes up when space becomes available. This keeps the system responsive and memory usage bounded. The buffer size becomes your contract with the producer. It defines how much work can be in flight at any moment.
Bounded channels: The manual valve
The most common tool for backpressure is a bounded channel. tokio::sync::mpsc::channel creates a sender and receiver with a fixed capacity. When the buffer is full, the sender blocks.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
// Bounded channel: max 5 items in flight.
// The usize argument sets the buffer capacity.
let (tx, mut rx) = mpsc::channel::<String>(5);
// Spawn a slow consumer.
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
// Simulate slow work.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
println!("Processed: {}", msg);
}
});
// Fast producer.
for i in 0..10 {
let msg = format!("Message {}", i);
// send() returns a Result.
// It awaits if the buffer is full, applying backpressure.
match tx.send(msg).await {
Ok(()) => println!("Sent {}", i),
Err(e) => eprintln!("Channel closed: {}", e),
}
}
}
The producer sends messages 0 through 4 instantly. The buffer fills. When the producer tries to send message 5, tx.send returns Poll::Pending. The task parks. The runtime switches to the consumer. The consumer wakes up, takes message 0, and frees a slot. The runtime notifies the sender. The sender unblocks and sends message 5. The flow stabilizes. The producer never outruns the consumer by more than five messages.
Convention aside: always check the Result from send. In tutorials, you see tx.send(msg).await.unwrap(). In production, the receiver might drop. If the receiver drops, send returns a SendError. Unwrapping panics the producer. Checking the result lets you handle the failure gracefully. Use let _ = tx.send(msg).await only if you explicitly decide to ignore the error, which signals to readers that you considered the value and chose to drop it.
The buffer size is your contract. Pick a number that reflects reality, not magic.
Framed codecs: The automatic valve
Network streams require a different approach. You are reading bytes from a socket and writing bytes back. If you read faster than you can write, your internal buffers grow. tokio_util::codec::Framed handles this automatically. It couples the read and write pressure.
use tokio::net::TcpStream;
use tokio_util::codec::{Framed, LengthDelimitedCodec};
async fn handle_connection(stream: TcpStream) {
// Codec handles framing. Capacity limits the internal buffer.
// If the write buffer fills up, reads pause automatically.
let mut framed = Framed::with_capacity(
stream,
LengthDelimitedCodec::new(),
64 * 1024, // 64KB buffer limit
);
// Reading here will pause if the write buffer is full.
// This couples the read/write pressure.
while let Some(Ok(frame)) = framed.next().await {
// Process frame...
// Writing back might fill the buffer.
let _ = framed.send(frame).await;
}
}
Framed maintains an internal buffer for outgoing data. When you call send, data goes into the buffer. If the buffer exceeds the capacity you set, Framed stops reading from the socket. The next call to next() yields without consuming data. The OS TCP stack sees the socket read buffer filling up and applies backpressure to the remote sender. The remote sender slows down. You don't manage this logic manually. The codec does it for you.
This pattern is essential for proxies and echo servers. If you read into a separate buffer without coupling it to the write side, you risk buffering gigabytes of data in memory while the write side crawls. Let the codec handle the pressure. Don't reinvent the buffer logic.
Realistic pattern: The bounded writer
In real applications, you often need to send data to a resource that has its own limits, like a database or an external API. You want to batch requests and limit concurrency. A bounded channel wrapped in a struct provides a clean interface.
use tokio::sync::mpsc;
struct DbWriter {
tx: mpsc::Sender<String>,
}
impl DbWriter {
/// Creates a writer that buffers up to `max_pending` queries.
fn new(max_pending: usize) -> Self {
let (tx, rx) = mpsc::channel(max_pending);
tokio::spawn(async move {
// Consumer loop runs in the background.
while let Some(query) = rx.recv().await {
// Simulate DB write latency.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
println!("Wrote: {}", query);
}
});
Self { tx }
}
/// Attempts to send a query. Returns error if buffer is full.
async fn write(&self, query: String) -> Result<(), String> {
// try_send is non-blocking.
// This is backpressure via rejection.
self.tx.try_send(query).map_err(|_| "DB overloaded, try later".to_string())
}
}
This struct exposes try_send. Unlike send, try_send does not block. It returns an error immediately if the buffer is full. This allows the caller to decide how to handle backpressure. The caller might retry later, drop the message, or return a 503 error to the client. This is backpressure via rejection. It's faster than blocking, but it requires the caller to handle the error path.
Convention aside: use try_send for fire-and-forget data like metrics or logs. If the buffer is full, losing a metric is acceptable. Use send for critical data where every message must be delivered. The choice depends on your failure mode.
Pitfalls and compiler traps
Unbounded channels are the enemy of backpressure. mpsc::unbounded_channel creates a channel with no limit. The sender never blocks. If the producer is faster than the consumer, memory grows until the process crashes. Only use unbounded channels when you are certain the producer rate is bounded or the consumer is always faster. In most cases, a bounded channel is the safer default.
If you try to send types that aren't thread-safe across tasks, the compiler stops you. Rc<T> is not Send. If you attempt to create a channel of Rc<String>, the compiler rejects you with E0277 (trait bound not satisfied). Rc uses reference counting that isn't safe for concurrent access. Use Arc<T> instead, or keep the channel single-threaded by using tokio::sync::oneshot or a single-threaded runtime.
Another trap is forgetting to await the send. tx.send(msg) returns a future. If you don't await it, the send never happens. The future is dropped, and the message is lost. The compiler warns about unused futures, but if you suppress the warning, the bug hides until runtime. Always await the send or explicitly drop the future with let _ = tx.send(msg);.
Decision matrix
Use tokio::sync::mpsc::channel with a small bound when you are building a pipeline between tasks and need to cap memory usage. The sender blocks when the buffer fills, naturally slowing the producer.
Use tokio_util::codec::Framed::with_capacity when you are processing network frames and want the read side to pause automatically if the write buffer is full. This couples the socket's read/write pressure without manual logic.
Use tx.try_send() when you want to reject work immediately rather than blocking the producer. This is useful for metrics or logs where losing data is acceptable under load.
Use tx.send().await when every message must be delivered and the producer can afford to wait. This preserves order and guarantees no data loss, at the cost of latency spikes when the consumer falls behind.
Match the backpressure strategy to your failure mode. Blocking is safe. Dropping is fast. Choose wisely.