How to Build an Event Loop in Rust

Build a Rust event loop using the tokio runtime and an async infinite loop that yields control via await.

The problem with threads

You are building a chat server. Players connect, send messages, and disconnect. In a traditional model, you spawn a new thread for every connection. It works for ten players. It works for a hundred. At a thousand players, your server starts thrashing. Threads are heavy. Each one consumes stack memory, and the operating system struggles to context-switch between thousands of them. You hit the OS limit, and new connections get rejected.

You need a way to handle thousands of concurrent connections on a handful of threads. You need an event loop.

An event loop is a single thread that manages many tasks. It checks each task to see if it has work to do. If a task is ready, the loop runs a step of that task. If a task is waiting for I/O or a timer, the loop moves on to the next one. The loop never blocks. It keeps spinning, juggling all the active tasks, ensuring progress even when some tasks are stalled.

The event loop concept

Think of a chef in a busy kitchen with ten orders. The chef doesn't stand over one pan until the steak is done. The chef checks order one: the pan is hot, flip the steak. Check order two: the soup is boiling, stir it. Check order three: the rice is still cooking, nothing to do yet. The chef moves to order four.

The chef is the event loop. The orders are tasks. The chef only spends time on an order when there is an action to take. While the rice cooks, the chef is free to work on other orders. The kitchen stays efficient because the chef never waits idly.

In Rust, you rarely write the loop yourself. You write the tasks, and a runtime library runs the loop for you. The runtime handles the juggling. Your job is to write tasks that yield control when they are waiting.

Minimal example

The most common way to run an event loop in Rust is with the tokio runtime. You mark your main function with #[tokio::main]. This macro generates the event loop and starts it. Inside the function, you write async code. The await keyword is where your task yields control back to the loop.

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

/// Runs a simple infinite loop that yields to the runtime every second.
#[tokio::main]
async fn main() {
    // The runtime starts here. It runs the loop on the current thread.
    loop {
        println!("Event loop iteration");
        
        // Yield control to the runtime.
        // Other tasks can run while this one sleeps.
        sleep(Duration::from_secs(1)).await;
    }
}

The sleep call returns a Future. When you .await it, the runtime takes over. The runtime registers the future with the OS timer. The runtime then moves on to other tasks. When the timer fires, the runtime wakes the future up, and the code after await resumes.

Convention aside: #[tokio::main] is for binary crates. If you are writing a library, do not start the runtime. Expose async fn functions and let the user decide how to run them. Starting the runtime in a library forces your users to use tokio, which breaks compatibility if they use a different runtime.

How the runtime actually works

Under the hood, an async fn does not run like a normal function. The compiler transforms it into a state machine. This state machine implements the Future trait. The runtime drives the state machine by calling a poll method.

Every future has a poll method. The runtime calls poll to ask, "Are you done?" The future returns Poll::Ready with a value if it finished, or Poll::Pending if it needs to wait. When a future returns Pending, it hands the runtime a Waker. The Waker is a callback. The runtime stores the Waker. When the external event happens (data arrives, timer fires), the runtime calls the Waker, which puts the future back in the queue to be polled again.

You can see the mechanics by implementing a Future manually. You never need to do this in production code, but it reveals how the loop drives your logic.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

/// A simple future that counts up to 5 before completing.
struct CountFuture {
    count: u32,
}

impl Future for CountFuture {
    type Output = ();

    /// The runtime calls this method to advance the future.
    fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
        // Advance the state.
        self.count += 1;
        
        if self.count < 5 {
            // Not done yet.
            // Return Pending so the runtime knows to check back later.
            Poll::Pending
        } else {
            // Done. Return the result.
            Poll::Ready(())
        }
    }
}

The Pin type ensures the future cannot move in memory while it is being polled. Futures often hold pointers to their own state. If the future moved, those pointers would dangle. Pin locks the future in place. The compiler handles Pin for you when you use async blocks. You only see Pin when you implement traits manually or use unsafe code.

The runtime maintains a queue of futures. It loops through the queue, calling poll on each one. If a future returns Ready, the runtime removes it and processes the result. If a future returns Pending, the runtime keeps it in the queue and waits for the Waker to fire. This cycle runs millions of times per second. The loop is the heartbeat of your application.

Realistic server example

A realistic event loop handles multiple concurrent tasks. You use tokio::spawn to create a new task for each connection. The spawn call hands the future to the runtime. The runtime schedules it alongside other tasks. The main loop continues immediately to accept the next connection.

use tokio::net::TcpListener;
use tokio::io::AsyncReadExt;

/// Handles a single client connection.
async fn handle_connection(mut socket: tokio::net::TcpStream) -> Result<(), Box<dyn std::error::Error>> {
    let mut buf = [0; 1024];
    
    // Read data from the socket.
    // This yields to the runtime while waiting for data.
    let n = socket.read(&mut buf).await?;
    
    println!("Received {} bytes", n);
    Ok(())
}

/// Runs a TCP server that handles connections concurrently.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    
    loop {
        // Accept a new connection.
        // This yields while waiting for a client.
        let (socket, addr) = listener.accept().await?;
        
        println!("New connection from {}", addr);
        
        // Spawn a task to handle the connection.
        // The loop continues immediately to accept the next connection.
        tokio::spawn(async move {
            if let Err(e) = handle_connection(socket).await {
                eprintln!("Error handling connection: {}", e);
            }
        });
    }
}

The tokio::spawn call requires the future to be Send and 'static. Send means the future can be moved across threads. The runtime may move tasks between threads to balance load. If your future holds a non-Send type, the compiler rejects it with E0277 (trait bound not satisfied). This usually happens if you use Rc instead of Arc, or if you hold a reference with a lifetime that doesn't live long enough.

Convention aside: tokio::spawn creates a "task". Tasks are lightweight and share the thread pool. Do not confuse tasks with OS threads. A task is a future scheduled by the runtime. You can have millions of tasks on a few threads.

Pitfalls and errors

The biggest mistake in async Rust is blocking the event loop. If a task calls a blocking function, it holds the thread hostage. No other tasks can run on that thread. The server stops responding. The game freezes. The loop dies.

Never call std::thread::sleep in async code. Use tokio::time::sleep instead. The standard library sleep blocks the thread. The tokio sleep yields to the runtime.

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

#[tokio::main]
async fn main() {
    // BAD: Blocks the thread. The runtime cannot run other tasks.
    // thread::sleep(Duration::from_secs(1));
    
    // GOOD: Yields to the runtime. Other tasks can run.
    sleep(Duration::from_secs(1)).await;
}

If you accidentally block the loop, tokio detects it and panics with a "Blocking on the executor" error. This panic saves you from a silent deadlock. Treat this panic as a hard stop. Find the blocking call and replace it with an async equivalent, or offload the work to a thread pool using tokio::task::spawn_blocking.

Another common issue is holding references across await points. The compiler enforces this strictly. If you borrow a value and then await, the borrow must live across the yield point. The future captures the borrow. If the borrowed value is dropped before the future completes, you get a use-after-free. The compiler prevents this by requiring the borrow to outlive the future.

If you try to move a value out of a borrowed context, the compiler rejects you with E0507 (cannot move out of borrowed content). This often happens when you try to extract data from a struct inside an async block without cloning or referencing it correctly.

The borrow checker in async code can be tricky. The state machine generated by the compiler holds references to captured variables. If a variable is borrowed mutably, the future holds a mutable borrow. You cannot access that variable while the future is pending. If you need to share mutable state across tasks, use Arc<Mutex<T>> or Arc<RwLock<T>>.

When to use what

Use tokio when you need high performance, I/O heavy workloads, and the largest ecosystem of async crates. It is the de facto standard for production Rust services.

Use async-std when you want an API that mirrors the standard library closely and prefer a runtime that integrates more tightly with std types. It is a solid alternative with a different architecture.

Write a manual event loop when you are building a runtime yourself, embedding Rust in a constrained environment without a runtime library, or implementing a custom scheduler for real-time guarantees. Manual loops are complex and error-prone. Only do this if you have a specific reason to avoid existing runtimes.

Use tokio::task::spawn_blocking when you must call a blocking library function. This offloads the work to a separate thread pool so the event loop stays responsive.

Use tokio::select! when you need to wait on multiple futures and react to whichever one completes first. It is the async equivalent of a switch statement.

Treat the event loop as the heartbeat of your application. If you block it, the app dies. Keep your tasks yielding, and the runtime will handle the rest.

Where to go next