async/await in JavaScript vs Rust vs Python
Compare async/await in JavaScript, Rust, and Python: see when functions start, what happens when you forget await, and how to run tasks concurrently.
A JavaScript async function starts running the moment you call it. A Rust async fn or a Python async def does nothing at all until something awaits it or schedules it.
This difference in when async work starts causes a familiar bug. The Rust or Python port of working TypeScript either never runs the function, or runs calls one after another when you expected them to overlap. This article runs the same small snippet in Node.js, Rust with Tokio, and Python 3.11+, and shows the output order for each. It assumes you already know JavaScript async. If you want that background first, see the OpenReplay guides on best practices for async programming in JavaScript and JavaScript async tips, tricks, and gotchas.
Key Takeaways
- Calling an async function in JavaScript runs its body synchronously up to the first
await, so code before thatawaitruns immediately. - Calling a Rust
async fnreturns aFutureand runs none of the body until the future is awaited or handed to a runtime such as Tokio. - Calling a Python
async deffunction returns a coroutine object that runs nothing until it is awaited or wrapped in a task. - If you create two async calls and then await each one in turn, the work overlaps in JavaScript but runs one after the other in Rust and Python.
- JavaScript runtimes come with an event loop built in. Rust needs an executor such as
#[tokio::main], and Python starts its loop withasyncio.run().
One Snippet, Three Languages, Three Outputs
Each version below defines the same function: print, await a 10 ms sleep, print again. Each one then calls the function without awaiting it. Only JavaScript prints a: start.
The samples target a Node.js LTS release, Tokio 1.x, and Python 3.11 or later.
JavaScript (Node.js)
async function work(label) {
console.log(`${label}: start`);
await new Promise((r) => setTimeout(r, 10));
console.log(`${label}: end`);
}
work("a"); // no await
console.log("main: after call");
a: start
main: after call
a: end
Rust (Tokio) with tokio = { version = "1", features = ["macros", "rt", "time"] }
use std::time::Duration;
async fn work(label: &'static str) {
println!("{}: start", label);
tokio::time::sleep(Duration::from_millis(10)).await;
println!("{}: end", label);
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
work("a"); // future created, never awaited
println!("main: after call");
}
main: after call
Python 3.11+
import asyncio
async def work(label):
print(f"{label}: start")
await asyncio.sleep(0.01)
print(f"{label}: end")
async def main():
work("a") # coroutine created, never awaited
print("main: after call")
asyncio.run(main())
main: after call
Why Doesn’t Rust async/await Run Until Awaited?
The difference between JavaScript, Rust, and Python async/await is what calling an async function returns. JavaScript returns a Promise for work that has already started. Rust and Python return an object that represents work that hasn’t started yet.
- JavaScript: an
async functionruns synchronously until its firstawait. The Promise you get back belongs to a body that is already running. - Rust: an
async fnreturnsimpl Future. TheFuturetrait describes a value that makes progress only when it is polled. Nothing in the body runs until you write.awaitor pass the future to something liketokio::spawn. - Python: calling an
async deffunction gives you a coroutine object. The asyncio docs say plainly that calling a coroutine does not schedule it to run.
The research paper by Gray, Krishnamurthi and Crichton describes this choice as an “Eagerness” dimension of async design, with three options: Lazy, Eager and Semi-Eager. In practice, JavaScript starts the work at the call site, and the other two wait.
What Happens When You Forget to Await?
All three languages let you forget await, but they fail in different ways. JavaScript still does the work, so the risk is an error that nothing catches. Rust and Python skip the work entirely and tell you about it.
JavaScript. The body runs, but if it rejects and nothing handles the rejection, Node treats it as an error. The --unhandled-rejections default mode is throw, which raises the rejection as an uncaught exception and ends the process unless you have a handler registered:
async function save() { throw new Error("disk full"); }
save(); // no await, no .catch()
Rust. The compiler warns at build time through the unused_must_use lint:
warning: unused implementer of `Future` that must be used
= note: futures do nothing unless you `.await` or poll them
Python. When the unawaited coroutine is garbage-collected, CPython prints a warning to stderr. The asyncio guide covers this under detecting never-awaited coroutines:
RuntimeWarning: coroutine 'work' was never awaited
The fix is the same in each language: await work("a") in JavaScript and Python, and work("a").await in Rust. With that change, all three print a: start, a: end, main: after call.
How Do You Run Async Work Concurrently?
In JavaScript, calling the functions is enough to get concurrency. In Rust and Python, you have to ask for it with a combinator or a task.
The create-then-await pattern shows the difference clearly:
const pa = work("a"), pb = work("b");
await pa; await pb; // JS: a: start, b: start, a: end, b: end
let fa = work("a"); let fb = work("b");
fa.await; fb.await; // Rust: a: start, a: end, b: start, b: end
ca = work("a"); cb = work("b")
await ca; await cb # Python: a: start, a: end, b: start, b: end
Neither the Rust future nor the Python coroutine starts until it is awaited, so each call runs only once the previous one has finished. The fixes:
| Language | Wait for all | Start now, await later |
|---|---|---|
| JavaScript | Promise.all | Just call the function |
| Rust | tokio::join! | tokio::spawn |
| Python | asyncio.gather, asyncio.TaskGroup | asyncio.create_task |
tokio::join! runs several futures concurrently inside the current task. tokio::spawn instead gives the future to the runtime as a separate task and returns a JoinHandle you can await later. A spawned future must be Send + 'static, which is why work takes &'static str.
asyncio.TaskGroup was added in Python 3.11. It waits for every task when its async with block exits, and if one task raises, it cancels the others:
async with asyncio.TaskGroup() as tg:
tg.create_task(work("a"))
tg.create_task(work("b"))
Even spawning a task is not as eager as a JavaScript call. A spawned body doesn’t run until the caller yields:
let h = tokio::spawn(work("a"));
println!("main: after spawn");
h.await.unwrap();
t = asyncio.create_task(work("a"))
print("main: after create_task")
await t
Both print main: after ... first, then a: start and a: end. JavaScript prints a: start before the caller’s next line. One caveat: the Rust order holds on current_thread. On a multi-threaded runtime, another worker can pick up the spawned task straight away, so the lines may interleave differently.
Where Does the Event Loop Come From?
JavaScript runtimes come with an event loop built in, while Rust and Python make you start one: Rust through an executor such as #[tokio::main], and Python through asyncio.run().
- JavaScript: Node.js and browsers already run an event loop before your code starts. The OpenReplay article on the JavaScript event loop and call stack explains how it works. In Node, top-level
awaitworks in ES modules (.mjsfiles, or"type": "module"inpackage.json). - Rust: the standard library has no executor.
#[tokio::main]wrapsasync fn mainin a Tokio runtime. It uses the multi-threaded runtime by default (which needs thert-multi-threadfeature), andflavor = "current_thread"switches to a single-threaded one. - Python:
async defis part of the language and doesn’t need any import. To run a coroutine, though, you need a loop, andasyncio.run()creates one, runs your top-level coroutine, and then closes the loop.
Side-by-Side Summary
| JavaScript | Rust (Tokio) | Python (asyncio) | |
|---|---|---|---|
| Calling an async function | Runs to first await | Returns an inert Future | Returns an inert coroutine |
Forgotten await | Work runs; rejection may crash Node | Compiler warning, no work | RuntimeWarning, no work |
| Concurrency | Promise.all | join! / spawn | gather / TaskGroup / create_task |
| Runtime | Built in | Choose an executor | asyncio.run() |
Conclusion
async/await syntax looks the same in JavaScript, Rust, and Python, but the three languages disagree about when the work begins. JavaScript starts it at the call. Rust and Python start it only when you await the result or explicitly schedule it. When you port async code, check every call site where the result isn’t awaited straight away. In Rust, replace those calls with tokio::join! or tokio::spawn. In Python, use asyncio.gather, TaskGroup or create_task.
FAQs
Can Python asyncio tasks start eagerly like JavaScript async functions?
Yes, as an opt-in. Python 3.12 added asyncio.eager_task_factory, which you install with loop.set_task_factory(asyncio.eager_task_factory). After that, a new task starts running its coroutine immediately inside the create_task call, up to the first point where it blocks. If the coroutine finishes without blocking, it never goes onto the event loop. Lazy scheduling remains the default, and switching to eager tasks can change execution order.
What happens to a tokio::spawn task if you drop its JoinHandle?
The task keeps running. Dropping a JoinHandle detaches the task, so it continues in the background, but its return value is lost and nothing can join on it. This differs from an unawaited future returned by an async fn call, which never runs at all. To stop a spawned task, call abort() on the JoinHandle. Awaiting an aborted task returns a JoinError that reports cancellation.
What is the difference between asyncio.gather and asyncio.TaskGroup when one task fails?
asyncio.gather does not cancel the other tasks when one raises. With the default return_exceptions=False, the first exception reaches the awaiting code immediately while the remaining tasks keep running. With return_exceptions=True, exceptions come back in the results list instead. asyncio.TaskGroup, added in Python 3.11, cancels the remaining tasks when one fails, so failed work does not keep running unnoticed.
Why can a fire-and-forget asyncio task disappear before it finishes?
The asyncio event loop keeps only weak references to tasks, so garbage collection can remove a task from asyncio.create_task mid-execution if your code never stores it. The Python docs recommend keeping a reference, for example by adding each task to a set and calling task.add_done_callback(background_tasks.discard). An asyncio.TaskGroup avoids the problem because it holds a strong reference to each task and awaits them.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k