# The Real Cost of an async Call

> Awaiting a coroutine that never suspends costs 53 ns against 24 ns for a plain call. Wrapping the same coroutine in a Task and awaiting it costs 28,958 ns.

- Published: 2026-08-12
- Tags: concurrency, memory, performance
- Source: https://pydepth.com/blog/the-real-cost-of-an-async-call/
- Language: en-US
- Author: Elliot Sayer

---
`async def` is often discussed as though it were a different language with different
performance rules. It is a keyword that makes a function return a coroutine object, and the
costs are small, specific and measurable — except for one, which is enormous and easy to
write by accident.

## Four shapes of the same function

The harness times the same trivial addition four ways: called directly, awaited as a
coroutine, wrapped in a Task and awaited one at a time, and gathered a thousand at a time. A
bare `await asyncio.sleep(0)` is included as the floor for one loop iteration.

```
$ python3 experiments/asynccost/calls.py
python 3.13.2 on darwin
200,000 iterations each

shape                 per op   vs plain
plain call             24 ns       1.0x
await coroutine        53 ns       2.1x
await Task          28958 ns    1182.4x
await sleep(0)      14519 ns     592.8x
gather, 1000 wide    1914 ns      78.1x

coroutine object: 192 bytes
```

## The cheap one

Awaiting a coroutine that never suspends costs 53 ns against 24 ns for the plain call. That
29 ns buys the coroutine object, the frame that gets suspended and resumed, and the protocol
by which `await` drives it. It is a constant, it does not depend on what the coroutine does,
and against any function body that touches a dictionary or formats a string it disappears.

This is the number to have in mind when someone proposes rewriting a helper as `async def`
so it can be awaited from an async caller. The rewrite is fine. It is not a performance
decision in either direction.

The other constant worth knowing is the object: 192 bytes per coroutine on this build. That
is cheap next to a thread, which is the comparison that matters, and expensive next to
nothing, which is the comparison that catches people. A hundred thousand coroutines held in
a list are 19 MB before a single one has been scheduled.

## The expensive one

`await asyncio.ensure_future(coro)` in a loop measured 28,958 ns per iteration — a thousand
times the plain call, and five hundred times the direct await. That is the spelling the
harness uses; for a coroutine argument `asyncio.create_task` is the same operation.

Nothing about the coroutine changed. What changed is that the result is no longer produced by
a function call; it is produced by the event loop. Creating the Task puts a callback on the
ready queue and returns. Awaiting it suspends the caller. The loop finishes the current
callback, finds nothing else runnable, polls the selector, wakes, and runs the Task. Then it
does the same thing again to deliver the result to the waiting caller.

The `await asyncio.sleep(0)` row is the same story with the Task removed: 14,519 ns for one
deliberate trip around the loop and back. That is the price of a loop iteration on this
machine, and awaiting a Task individually costs roughly two of them.

## The one that matters

The last row is the same coroutines run a thousand at a time under `gather`: 1,914 ns each.
Same Task machinery, same loop, one round trip amortised across a thousand pieces of work
instead of paid per piece.

This is the actual rule, and it is not about `async` at all. The event loop charges per trip,
not per coroutine. Work that goes through it in a batch is cheap; work that goes through it
one item at a time pays the full fixed cost every time.

Which is why the shape below is the most expensive way to write the same program:

```python
for row in rows:
    await asyncio.create_task(process(row))   # a loop round trip per row
```

and this one is not:

```python
await asyncio.gather(*(process(row) for row in rows))
```

The first version creates concurrency and then immediately destroys it by waiting. It reads
like the second version. It performs like a synchronous loop with a syscall bolted onto each
iteration.

## Seeing it in a running program

The loop will tell you when it is being used this way, if it is asked. Debug mode logs any
callback that takes longer than a threshold, and the threshold is settable:

```python
loop = asyncio.get_running_loop()
loop.set_debug(True)
loop.slow_callback_duration = 0.005   # default is 0.1 seconds
```

What that surfaces is the opposite problem — a single callback hogging the loop — but the two
are diagnosed together, because both show up as a loop that is busy while nothing useful is
finishing. A cheap sanity check for the batching mistake is to count trips directly: wrap the
loop's `_run_once` during a request and compare the count against the number of items
processed. If they are the same, the work is going through one at a time.

## Where the real programs sit

None of these figures describe an application that is actually doing IO. A coroutine awaiting
a database round trip spends milliseconds waiting and nanoseconds on the machinery above;
the overhead is invisible against the wait, which is the entire point of the design.

They describe the boundary — the code that decides how work reaches the loop. That code is
usually a loop over a list, it is usually written once, and it is where the difference
between 1,914 ns and 28,958 ns per item is decided.

## Where to stop

Everything here is CPython 3.13.2 with the default event loop on macOS. The loop-trip cost in
particular is selector-dependent: a different platform, or `uvloop`, will produce a different
number for the `sleep(0)` row and therefore for the Task row.

The ordering is what transfers. Direct await is nearly free, a loop trip is not, and batching
is what turns the second into the first. If a profile shows time inside `asyncio.base_events`,
the answer is almost never a faster loop — it is fewer trips through the one already there.
