Skip to content
← All Python articles

Python explainer

Debug a Slow or Stuck Async Service

About 4 min to read. Allow extra time to work through examples.

Work out whether some code blocks the event loop or one task is waiting, then find the cause with debug mode, a task dump, Python 3.14's call graph and py-spy.

This article uses Python. You can make it your practice mode to browse related content across the site.

Your async service answers most requests in 20 milliseconds, but now and then one takes two seconds. Some requests hang forever. In async code, a slow or stuck request has one of two causes, and each needs its own tool:

  • The loop is blocked. Some code runs for a long time without reaching an await, so every other task waits. Every request slows down at once.
  • A task is waiting. The task sits at an await for something slow, or for something stuck, such as a lock its holder forgot to release. Other requests carry on.

Find blocking code with debug mode

In debug mode, asyncio logs every task step that holds the loop for longer than 100 milliseconds:

import asyncio
import time


async def handler() -> None:
    time.sleep(0.3)  # a blocking call inside async code


asyncio.run(handler(), debug=True)
Executing <Task finished name='Task-1' coro=<handler() done, defined at app.py:5> ...> took 0.305 seconds

A task step is the code a task runs between two points where it gives control back to the loop. A slow step means a blocking call or heavy computing somewhere in that stretch. The message names the task, not the line. So give tasks names, as in asyncio.create_task(coro, name="refresh-cache"), and read that task's code between its awaits. Move the blocking call to asyncio.to_thread.

You can also turn on debug mode with the environment variable PYTHONASYNCIODEBUG=1 or with python -X dev. When you forget to await a coroutine, debug mode adds the line that created it to the warning. Debug mode slows the loop down, so turn it on to investigate, and measure the cost before you leave it on in production.

The warning prints only when the step ends. A step that never ends, such as an infinite loop, prints nothing. The last section covers that case.

See what every task is waiting for

When requests hang but the loop still runs, list the tasks and where each one is suspended:

import asyncio
import sys


async def refresh_cache(release: asyncio.Event) -> None:
    await release.wait()  # stands in for a slow fetch


def dump_tasks() -> None:
    for task in sorted(asyncio.all_tasks(), key=lambda t: t.get_name()):
        print(f"task: {task.get_name()}")
        task.print_stack(limit=3, file=sys.stdout)


async def main() -> None:
    release = asyncio.Event()
    worker = asyncio.create_task(refresh_cache(release), name="cache-refresh")
    await asyncio.sleep(0)  # let the worker start and suspend
    dump_tasks()
    release.set()
    await worker


asyncio.run(main())

The entry for the worker shows the line it is suspended on:

task: cache-refresh
Stack for <Task pending name='cache-refresh' coro=<refresh_cache() running at app.py:6> ...>
  File "app.py", line 6, in refresh_cache
    await release.wait()  # stands in for a slow fetch

In a real service, call dump_tasks from an admin endpoint. It runs on the event loop, so it needs the loop to be free. Keep the output short, and keep secrets out of task names.

On Python 3.14, print who is waiting for whom

A stack shows where one task is suspended. To see which task is waiting for it, use asyncio.print_call_graph(task), new in Python 3.14. It prints the task's stack and then the tasks that await it:

* Task(name='fetch-user', id=0x106163df0)
  + Call stack:
  |   File 'app.py', line 5, in async fetch_user()
  + Awaited by:
    * Task(name='request-42', id=0x106420d70)

Python 3.14 can also inspect another running Python 3.14 process from the command line. Replace PID with its process ID:

python3.14 -m asyncio ps PID
python3.14 -m asyncio pstree PID

ps lists the pending tasks as a table, and pstree draws them as a tree of who awaits whom. Both read the other process's memory, so you may need extra permissions, such as sudo on macOS. Try them on your own machine before you need them in an incident.

When a step never ends, look from outside

If a step never ends, every task waits, your task dump included. Use a tool that reads the process from outside. py-spy is a profiler you install with pip install py-spy. py-spy dump --pid PID prints the current stack of every Python thread, so the event loop's thread shows the exact line it is stuck on.

Check your understanding Debug mode logs Executing <Task ... name='report' ...> took 1.200 seconds. What does that tell you? Show answer

The loop ran the report task for 1.2 seconds without switching to anything else. Somewhere between two of its awaits, it made a blocking call or did heavy computing, and every other task waited. Read that stretch of code, or profile it, and move the blocking call to asyncio.to_thread.

Check your understanding A request hangs forever, but debug mode logs nothing and other requests are fine. What do you check first? Show answer

A task dump. Other requests are fine, so the loop is running, and the stuck request is waiting at an await. The dump shows which one. On Python 3.14, print_call_graph also shows which task is waiting for it.

Going further

loop.slow_callback_duration sets the threshold for the slow-step warning, 0.1 seconds by default. Debug mode also checks for some asyncio calls made from the wrong thread.

Read more in Developing with asyncio, call graph introspection and the asyncio command-line tools.

step 44 of 56

Concurrency with Threads and asyncio

Up next Carry a Request ID Across Tasks with contextvars