How to Use BufReader and BufWriter for Performance in Rust

Use BufReader and BufWriter to buffer I/O operations on TcpStream, reducing system calls and boosting performance.

How to Use BufReader and BufWriter for Performance in Rust

You're building a TCP echo server. You read data from the socket one byte at a time. It works. Then you test it with a large message. The server grinds to a halt. Your CPU usage spikes, but the network throughput is pathetic. The bottleneck isn't your logic. It's the kernel. Every single byte you read triggers a system call, crossing the boundary between user space and kernel space. That context switch costs cycles. Doing it millions of times turns a fast program into a slow one.

BufReader and BufWriter solve this by adding a buffer. Think of a buffer like a bucket. Instead of asking the kernel for one drop of water at a time, you ask for a full bucket. You then sip from the bucket in your code. When the bucket is empty, you ask the kernel for another. BufWriter works in reverse. You pour your data into the bucket. The writer only asks the kernel to flush the bucket when it's full or when you explicitly tell it to. This reduces system calls from millions to a few dozen.

The buffer is a truce between your code and the system call overhead.

How buffering works

BufReader wraps a reader and adds a heap-allocated buffer. The default size is 8KB. When you call read, the wrapper checks its buffer. If the buffer has data, it returns that immediately. No system call. If the buffer is empty, it asks the underlying reader for a chunk of data, fills the buffer, and returns the first part.

BufWriter wraps a writer and adds a buffer. When you call write, the wrapper copies data into the internal buffer. It only calls the underlying writer's write when the buffer is full. This batching is where the performance gain comes from.

Here's the trap: BufWriter holds onto your data. If you drop the writer without flushing, the data might vanish. The Drop implementation tries to flush, but if the flush fails, the error is swallowed. You lose data silently. Always flush explicitly before dropping, or handle the error from into_inner.

Trust the buffer. It knows when to ask the kernel.

Minimal example

This example shows how to wrap a TCP stream for reading and writing. We split the stream first to avoid borrowing conflicts.

use std::io::{BufReader, BufWriter, Read, Write};
use std::net::TcpStream;

/// Handle a single connection with buffered I/O.
fn handle_connection(stream: TcpStream) {
    // Split the stream into independent read and write halves.
    // This allows us to wrap each half without borrowing conflicts.
    let (read_half, write_half) = stream.into_split();

    // Wrap each half. Ownership transfers to the buffers.
    // The buffers allocate 8KB by default.
    let mut reader = BufReader::new(read_half);
    let mut writer = BufWriter::new(write_half);

    // Read the request. BufReader handles chunking internally.
    let mut request = String::new();
    reader.read_to_string(&mut request).unwrap();

    // Write response. Data goes into the buffer first.
    writer.write_all(b"HTTP/1.1 200 OK\r\n\r\nHello").unwrap();

    // Flush is crucial. Without it, data stays in the buffer.
    writer.flush().unwrap();
}

Wrap early, unwrap late. Let the buffer handle the heavy lifting.

Inside the buffer

When you create a BufReader, it allocates a buffer on the heap. The BufRead trait exposes two methods that reveal how this works: fill_buf and consume.

fill_buf returns a slice of the buffer. If the buffer is empty, it reads data from the underlying source. consume tells the reader how many bytes you used. This pattern allows zero-copy parsing. You can parse headers directly from the buffer slice without allocating a new string. High-performance parsers use this to avoid allocations.

BufWriter is simpler. It implements Write. Every write call copies data into the buffer. When the buffer fills, it flushes automatically. You can also call flush manually. This gives you control over when data hits the wire.

Convention aside: BufReader::new(stream) is the standard. BufReader::with_capacity(size, stream) exists but is rarely needed. The community defaults to new. If you see with_capacity, expect a comment explaining why. Don't reach for it preemptively. The default 8KB is tuned for page alignment and works well for most workloads.

Realistic example

In a real server, you often read lines or messages in a loop. BufReader implements BufRead, which provides a lines iterator. This is more efficient than read_to_string for line-based protocols.

use std::io::{BufRead, BufReader, BufWriter, Write};
use std::net::TcpStream;

/// Handle an echo protocol that reads lines until empty.
fn handle_echo(stream: TcpStream) {
    let (read_half, write_half) = stream.into_split();
    let mut reader = BufReader::new(read_half);
    let mut writer = BufWriter::new(write_half);

    // BufReader implements BufRead, which gives us lines().
    // This iterator handles buffering and line splitting efficiently.
    for line in reader.lines() {
        // Unwrap for brevity. In production, handle errors properly.
        let line = line.unwrap();

        // Empty line signals end of input.
        if line.is_empty() {
            break;
        }

        // Echo back with a prefix.
        let response = format!("ECHO: {}\n", line);
        writer.write_all(response.as_bytes()).unwrap();

        // Flush after each response so the client sees it immediately.
        // Batching flushes improves throughput but increases latency.
        writer.flush().unwrap();
    }
}

Use lines() for text protocols. It saves you from reinventing the wheel.

Pitfalls and errors

Borrowing conflicts

If you wrap a stream in BufReader, the stream is consumed. You can't call stream.write() anymore. The compiler rejects this with E0382 (use of moved value) if you try to use the stream after wrapping.

If you need the stream later, call reader.into_inner() to unwrap it. This returns the original stream. Be careful: any buffered data is lost. You can't recover it.

Accessing the inner stream

BufReader and BufWriter provide get_ref and get_mut. get_ref returns an immutable reference to the inner stream. This is safe. You can use it to check stream properties.

get_mut returns a mutable reference. This is dangerous for BufWriter. If the buffer has data, writing to the inner stream causes data loss or corruption. The docs warn against this. Only use get_mut if you're sure the buffer is empty.

Silent flush failures

BufWriter flushes on drop. If the flush fails, the error is ignored. This is a silent failure risk. In network servers, this means the client might not receive the last message. Always flush explicitly and check the error before dropping the writer.

Buffer size myths

Buffer size matters less than you think. The default 8KB is tuned for page alignment. Changing it rarely helps unless you have a very specific workload. If you're reading 1MB messages, the buffer refills 128 times. That's still 128 syscalls instead of millions. The win is massive regardless of buffer size. Focus on correctness first. Tune buffer size only after profiling proves it's a bottleneck.

Check the flush error. Silent drops are the enemy of reliable servers.

When to use buffering

Use BufReader when you're reading from a slow source like a network socket or file and want to reduce system calls. Use BufReader when you need BufRead methods like lines() or read_line(). Use BufWriter when you're writing small chunks frequently and want to batch them into larger writes. Use BufWriter when you need to control exactly when data hits the wire by calling flush(). Reach for the raw stream when you need zero-copy access or are implementing a custom buffering strategy. Reach for BufReader and BufWriter together when you're building a protocol handler that reads and writes in a loop.

Buffering is free performance. Take it.

Where to go next