Skip to content

Async/Await and Tokio

Async/await: the most familiar Rust concurrency model for TS developers

Section titled “Async/await: the most familiar Rust concurrency model for TS developers”

If you have written TypeScript, you already know the mental model behind Rust async/await. Both languages use the same surface syntax (async fn, .await) to write non-blocking code that reads like sequential logic. The key concepts map almost one-to-one:

JavaScriptRust
async functionasync fn
await somePromisesomeValue.await
Promise<T>impl Future<Output = T>
Promise.all(...)tokio::join!(...) or futures::join_all(...)
Node.js event loop (built-in)Tokio runtime (explicit dependency)
Promise.allSettledtokio::join! (all always complete)

The most important difference: JavaScript’s runtime (V8, Node.js) is always running. In Rust, there is no built-in async runtime — you must bring your own. Tokio is the de facto standard for server-side async Rust (web servers, databases, network services).

An async fn returns a Future — a lazy computation that does nothing until it is polled by a runtime. You .await a Future to run it and get its result. This is syntactically identical to JavaScript’s await.

TypeScript
// TypeScript async/await
async function fetchUser(id: number): Promise<string> {
const resp = await fetch(`/users/${id}`);
const data = await resp.json();
return data.name;
}
// Run concurrently with Promise.all
async function main() {
const [user1, user2] = await Promise.all([
fetchUser(1),
fetchUser(2),
]);
console.log(user1, user2);
}
Rust
use tokio::time::{sleep, Duration};
// async fn returns a Future<Output = String>
async fn fetch_user(id: u32) -> String {
// .await yields control to the runtime while "waiting"
sleep(Duration::from_millis(10)).await;
format!("user_{id}")
}
#[tokio::main] // sets up the Tokio runtime for main()
async fn main() {
// tokio::join! runs both futures concurrently on the same thread pool
// — equivalent to Promise.all
let (user1, user2) = tokio::join!(
fetch_user(1),
fetch_user(2),
);
println!("{user1} {user2}"); // user_1 user_2
}

tokio::spawn is the async equivalent of thread::spawn. It schedules a future on the Tokio thread pool and returns a JoinHandle<T>. Unlike thread::spawn, the task runs on a thread pool managed by Tokio — typically one thread per CPU core.

TypeScript
// TypeScript: fire-and-forget with Promise (or await to collect result)
async function work(id: number): Promise<number> {
await new Promise(resolve => setTimeout(resolve, 10));
return id * 2;
}
async function main() {
// Spawn all, then await all — equivalent to tokio::spawn + join
const handles = [1, 2, 3].map(id => work(id));
const results = await Promise.all(handles);
const total = results.reduce((a, b) => a + b, 0);
console.log('total:', total); // total: 12
}
Rust
use tokio::time::{sleep, Duration};
async fn work(id: u32) -> u32 {
sleep(Duration::from_millis(10)).await;
id * 2
}
#[tokio::main]
async fn main() {
let handles: Vec<_> = (1u32..=3)
.map(|id| tokio::spawn(work(id)))
.collect();
let mut total = 0u32;
for h in handles {
total += h.await.unwrap();
}
println!("total: {total}"); // total: 12
}
use tokio::time::{sleep, Duration};
async fn fetch_user(id: u32) -> String {
sleep(Duration::from_millis(10)).await;
format!("user_{id}")
}
#[tokio::main]
async fn main() {
let (user1, user2) = tokio::join!(
fetch_user(1),
fetch_user(2),
);
println!("{user1} {user2}");
}
What is the most important difference between Rust async and JavaScript async?
What does #[tokio::main] do?
What is the JavaScript equivalent of tokio::join!(a, b)?
What happens if you create a Rust Future but never .await it?