How to use tokio crate in Rust async runtime

Tokio is Rust's most-used async runtime. Walk through #[tokio::main], spawning concurrent tasks, a TCP echo server, and the blocking-call trap that catches every beginner.

The day you needed async

You're building a small program that fetches three URLs and prints their lengths. Sequential code does it in 900ms. Then you realise you could fetch them at the same time and finish in 300ms. Same idea with reading from a database, listening on a socket, talking to a queue, waiting on a timer. The work isn't compute-heavy; it's waiting-heavy. Doing one thing at a time means most of the time you're idle.

This is what async programming is for, and in Rust the default async runtime is Tokio. The standard library gives you the async and await keywords, but it deliberately does not ship a runtime to drive them. Tokio is the runtime almost everyone reaches for. Hyper, reqwest, sqlx, axum, tonic, and most of the modern Rust web stack run on it.

This article walks through what Tokio is, how to wire it into a tiny project, and the small list of footguns that catch most beginners.

What Tokio actually does

Think of Tokio as a very efficient receptionist for async tasks. You hand it functions that occasionally have to wait for something (a network reply, a sleep, a file read). The receptionist parks each task whenever it has nothing useful to do, picks up another task that's ready, and routes the parked task back to a worker thread the moment its wait is over. With a handful of OS threads, Tokio can juggle thousands of waiting tasks.

The building blocks you'll meet are:

  • An async fn, which is a function that returns a Future. A Future is a value that knows how to make progress when polled.
  • The await operator, which says "pause this task here until the future is ready."
  • A runtime, which is the loop that polls futures and decides which one to run next. Tokio is one such runtime.
  • Tasks, which are independent futures the runtime owns. You start them with tokio::spawn.

Without a runtime, your async fn does nothing. It's just a struct waiting to be polled. The runtime is the engine.

Adding Tokio to a project

Tokio is feature-gated, which means you opt into the bits you need. The simplest start is the full feature, which turns everything on.

# Cargo.toml
[dependencies]
# "full" enables the multi-thread runtime, macros, IO, time, sync primitives, etc.
# Once you know what you actually use, you can switch to a smaller set.
tokio = { version = "1", features = ["full"] }

The shortest functioning async program looks like this:

// #[tokio::main] is a macro that wraps your async fn main with a real fn main
// that builds a runtime, drives the future to completion, and exits.
#[tokio::main]
async fn main() {
    println!("Hello from async Rust!");
}

What the macro expands to, roughly:

fn main() {
    // Build a multi-threaded runtime with one worker thread per CPU core.
    let rt = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap();

    // block_on runs the given future to completion on the current thread,
    // using the runtime's worker pool for any tasks spawned along the way.
    rt.block_on(async {
        println!("Hello from async Rust!");
    });
}

You rarely write the manual version. The macro saves you from boilerplate every project would otherwise repeat. But knowing what it does demystifies the magic when something goes wrong.

Spawning concurrent tasks

The payoff of async is being able to do several things at once without firing up real OS threads for each. Use tokio::spawn to hand the runtime an extra task.

use tokio::time::{sleep, Duration};

// A pretend job that takes some wall-clock time.
async fn fetch(name: &str, ms: u64) -> String {
    // sleep is async: it yields control while it waits, instead of blocking.
    sleep(Duration::from_millis(ms)).await;
    format!("{name} done")
}

#[tokio::main]
async fn main() {
    // spawn returns a JoinHandle, which is itself a Future you can await on later.
    let a = tokio::spawn(fetch("alice", 200));
    let b = tokio::spawn(fetch("bob", 200));
    let c = tokio::spawn(fetch("carol", 200));

    // Wait for all three. .unwrap() unwraps the JoinError.
    println!("{}", a.await.unwrap());
    println!("{}", b.await.unwrap());
    println!("{}", c.await.unwrap());
}

Three 200ms tasks finish in roughly 200ms total, not 600ms. The runtime overlaps their waits.

A more realistic example: a TCP echo server

Here's where Tokio earns its keep. A server that accepts TCP connections and echoes whatever bytes come in, handling many clients at once with a single thread per CPU core.

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // Bind to a port. The bind itself is async because it talks to the OS.
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    println!("listening on 127.0.0.1:8080");

    // The accept loop is sequential, but the per-connection work is spawned.
    loop {
        // accept() yields the next incoming connection.
        let (mut socket, addr) = listener.accept().await?;

        // spawn moves this connection's work to a separate task.
        // Many connections can be in flight at once, all handled by the runtime.
        tokio::spawn(async move {
            let mut buf = [0u8; 1024];
            loop {
                // read returns 0 when the client hangs up.
                let n = match socket.read(&mut buf).await {
                    Ok(0) => return,             // EOF
                    Ok(n) => n,
                    Err(_) => return,            // connection error, just drop it
                };
                // write_all retries until everything is sent or an error occurs.
                if socket.write_all(&buf[..n]).await.is_err() {
                    return;
                }
            }
            // _addr suppresses the unused-variable warning.
            #[allow(unused_variables)] let _addr = addr;
        });
    }
}

This server uses memory and CPU proportional to active work, not connection count. Ten thousand idle connections cost almost nothing.

The biggest beginner trap: blocking on the runtime

Tokio assumes your tasks are cooperative. They yield control back to the runtime whenever they're waiting on something, so other tasks can run. If you call a function that blocks (a sync HTTP client, std::thread::sleep, a heavy CPU loop), you freeze the worker thread until it returns. With the default multi-thread runtime, you've taken one core out of service. With a single-threaded runtime, you've stopped everything.

The rule of thumb: anything that takes more than a few microseconds and isn't async is a problem.

Fixes:

Use the async equivalent. tokio::time::sleep instead of std::thread::sleep. reqwest::get (async) instead of a sync HTTP call. tokio::fs::read instead of std::fs::read.

For genuinely sync code you can't avoid (a CPU-bound hash, an old library, a blocking SQLite call), wrap it in tokio::task::spawn_blocking. That hands the work off to a separate thread pool reserved for blocking work, leaving the async workers free.

let result = tokio::task::spawn_blocking(|| {
    // Heavy synchronous computation.
    expensive_hash(&data)
}).await.unwrap();

Choosing features instead of full

Once you know what you actually use, drop full for a smaller feature list. It cuts compile time and binary size.

tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time"] }

The ones you'll see most:

  • rt-multi-thread: the default work-stealing scheduler.
  • rt: a single-threaded scheduler (for very small programs or very particular use cases).
  • macros: provides #[tokio::main] and #[tokio::test].
  • net: TCP/UDP/Unix sockets.
  • time: sleeps, timeouts, intervals.
  • sync: channels and synchronization primitives.
  • fs: async filesystem.
  • signal: Unix signal handling.

If you forget a feature, the compiler fails with unresolved import. Add it and rebuild.

Common pitfalls

Forgetting .await. Calling fetch("alice", 200) returns the future but does nothing. The compiler usually warns: note: futures do nothing unless you .await or poll them.

Using std::thread::sleep inside async code. Replace with tokio::time::sleep.

Mixing runtimes. If you depend on a crate that uses async-std, you may run into incompatible runtime expectations. Stick with the Tokio ecosystem when you can.

Returning values that aren't Send from a tokio::spawn-ed task. The default multi-thread runtime needs to move tasks between worker threads. If your task captures, for example, an Rc<T>, you'll see cannot be sent between threads safely. Switch to Arc<T>.

When Tokio is the right call

Use Tokio when your work is dominated by waiting on I/O: HTTP services, database clients, network proxies, chat servers, anything with thousands of concurrent connections.

Use threads (or Rayon) when your work is CPU-bound: parsing huge files, image processing, mathematical pipelines.

For a mix, the typical pattern is a Tokio app that occasionally hands a CPU chunk off to spawn_blocking or to Rayon's pool.

Where to go next

How to Set Up the Tokio Runtime in Rust

How to Use tokio::spawn for Spawning Async Tasks

What is the tokio runtime