Diagnosing Blocked Event Loops in Production

An asyncio service has a failure mode that looks like everything and nothing: every endpoint slows at the same moment, CPU stays low, every dependency reports healthy, and there is no error anywhere. The cause is a synchronous call running on the loop thread, which stops every coroutine in the process until it returns. This page covers confirming that is what is happening and then finding which call — in staging with debug mode, and in production with a watchdog that captures the loop's stack at the moment of the stall. It is a task article under concurrency and GIL observability, part of the Python profiling and performance observability section.

Catching the stall while it is happening Two threads run side by side. The event loop thread runs coroutines normally until one of them calls a synchronous database driver, which holds the thread for three hundred milliseconds. During that time the loop does not advance and a heartbeat the loop updates every fifty milliseconds stops changing. A separate watchdog thread checks the heartbeat's age every twenty-five milliseconds; when the age exceeds a threshold of one hundred milliseconds, the watchdog reads the loop thread's current stack from the interpreter and logs it. Because the capture happens during the stall, the stack names the synchronous call directly, including the coroutine and the line that made it. The note records that the watchdog costs almost nothing when the loop is healthy, because it does nothing except compare two timestamps. a watchdog on a second thread loop thread sync driver call — 300 ms, loop frozen heartbeat stops advancing watchdog age > 100 ms → dump the captured stack names the blocking call, because it was taken during the stall views.py:88 get_order → psycopg2 cursor.execute — a synchronous driver inside a coroutine when the loop is healthy the watchdog only compares two timestamps, so it can run permanently
A stack captured after the stall shows healthy code. One captured during it shows the culprit, which is why the watchdog has to be on a different thread.

Prerequisites

pip install "prometheus-client>=0.20.0,<1.0.0"

Everything else used here — asyncio's debug mode, sys._current_frames, traceback — is in the standard library.

Implementation

Step 1 — Confirm the loop is blocked, not busy. Event loop lag high with CPU low is the signature of a synchronous wait on the loop. Lag high with CPU high is genuine computation on the loop, which is a related problem with a different remedy. The first is far more common, and it is also the one an on-CPU profiler cannot see, because the loop thread is waiting rather than executing. Measuring asyncio event loop lag covers the lag metric itself.

Step 2 — Use debug mode in staging to name slow callbacks. Debug mode makes the loop time every callback and log any that exceed a threshold. It names the coroutine directly, which is often enough. The overhead makes it unsuitable for production, and in staging under representative load it is the fastest route to an answer.

import asyncio, logging

logging.basicConfig(level=logging.WARNING)
loop = asyncio.new_event_loop()
loop.set_debug(True)
loop.slow_callback_duration = 0.05        # warn on anything holding the loop > 50 ms
asyncio.set_event_loop(loop)

Expected Output: the loop names the offending task and how long it held the thread.

WARNING:asyncio:Executing <Task pending name='Task-4182' coro=<get_order() running at app/views.py:88>> took 0.312 seconds

Step 3 — Install a watchdog for production. Debug mode is too expensive to leave on. A watchdog thread that checks a heartbeat the loop updates, and captures the loop thread's stack when the heartbeat goes stale, gives the same answer at almost no cost: when the loop is healthy it compares two timestamps, and it does real work only during a stall.

# loop_watchdog.py
import asyncio
import logging
import sys
import threading
import time
import traceback

log = logging.getLogger("loop-watchdog")
_heartbeat = time.monotonic()


async def _beat(interval: float = 0.05) -> None:
    global _heartbeat
    while True:
        _heartbeat = time.monotonic()
        await asyncio.sleep(interval)


def _watch(loop_thread_id: int, threshold: float, check_every: float) -> None:
    last_report = 0.0
    while True:
        time.sleep(check_every)
        stalled_for = time.monotonic() - _heartbeat
        if stalled_for < threshold or time.monotonic() - last_report < 5:
            continue
        # 1. Read the loop thread's CURRENT stack — during the stall, not after.
        frame = sys._current_frames().get(loop_thread_id)
        stack = "".join(traceback.format_stack(frame)) if frame else "<no frame>"
        log.warning("event loop blocked", extra={
            "stalled_ms": round(stalled_for * 1000), "stack": stack})
        last_report = time.monotonic()


def install(loop: asyncio.AbstractEventLoop, threshold: float = 0.1) -> None:
    loop.create_task(_beat())
    loop_thread_id = threading.get_ident()          # call from the loop's thread
    threading.Thread(target=_watch, args=(loop_thread_id, threshold, 0.025),
                     daemon=True, name="loop-watchdog").start()

Expected Output: the stack at the moment of the stall, with the synchronous call at the bottom.

WARNING loop-watchdog event loop blocked stalled_ms=287
  File "app/views.py", line 88, in get_order
    row = legacy_db.fetch_order(order_id)
  File "app/legacy_db.py", line 41, in fetch_order
    cur.execute(SQL, (order_id,))
  File "psycopg2/extensions.py", line ...

Step 4 — Check the usual culprits. Most incidents come from a short list. A synchronous database driver or HTTP client called inside a coroutine is the most common. A logging handler writing synchronously to a slow sink is the second, and the most surprising, because the call is a log.info that nobody suspects — the fix is in logging from asyncio tasks without blocking. Large JSON encoding or decoding, blocking DNS resolution in a library that does its own, and CPU-heavy work such as templating or compression make up most of the rest.

Step 5 — Move the call off the loop and confirm. A synchronous call that cannot be replaced with an async equivalent can run in a thread, which frees the loop while it waits. The confirmation is the lag distribution returning to its normal shape, not the absence of watchdog warnings, since a warning threshold can be crossed by smaller stalls that still matter.

import asyncio

async def get_order(order_id: str):
    # The synchronous driver now blocks a worker thread, not the loop.
    return await asyncio.to_thread(legacy_db.fetch_order, order_id)

Step 6 — Keep the watchdog installed. Blocking calls are reintroduced constantly — a new dependency with a synchronous client, a logging change, an innocent-looking library upgrade. A watchdog left running turns the next occurrence into a single log line with a stack, rather than another afternoon of investigation.

Why every endpoint slows at once Four endpoints share one event loop. Three of them — health, list products and get cart — are fast and entirely unrelated to the fourth, a legacy order lookup that makes a synchronous database call taking three hundred milliseconds. During that call the loop is held, so any request to the other three that arrives during the stall waits for it to finish before its coroutine can run. Their latency graphs show a spike at exactly the same moment, of roughly the same size, despite none of them doing anything differently. The note records that this simultaneous spike across unrelated endpoints is the defining signature of a blocked loop, and that looking for the cause in any of the three spiking endpoints is looking in the wrong place. four endpoints, one loop, one synchronous call /healthz /products /cart /orders (legacy) the synchronous call that held the loop three unrelated endpoints spike together — the cause is in none of them
The simultaneous spike across unrelated endpoints is the signature. Investigating any of the endpoints that spiked is investigating a victim.

Why this is so hard to find without the right tool

Blocked loops persist in production for longer than almost any other performance problem, and the reasons are structural.

Every usual signal points somewhere else. Request latency rises on every endpoint, so no endpoint stands out. Dependencies report healthy, because they are. CPU is low, so nobody suspects the process. Traces show slow spans across unrelated operations, with the time falling in gaps between spans rather than inside any of them — a coroutine waiting for the loop to become free is not inside any instrumented operation.

Profilers mislead by default. An on-CPU profile of a loop blocked on a synchronous socket read shows an almost idle process, because the thread is waiting in the kernel. Only a wall-clock profile, or a stack captured at the moment of the stall, shows the blocking call.

The cause is often innocent-looking. A log.info call, a requests.get for a feature flag, a json.dumps of a large response. None of these looks like a performance problem in review, and all of them block the loop. The watchdog's value is that it names the call regardless of how innocent it looks.

Intermittency hides it. A call that blocks for three hundred milliseconds only when a downstream service is slow, or only for large payloads, produces stalls that come and go with conditions nobody is correlating. Lag percentiles over time, with the watchdog's stacks attached to the spikes, turn an intermittent mystery into a pattern with a named cause.

Preventing the next one

Finding a blocking call is reactive. Three practices catch most of them before they reach production.

Fail tests on slow callbacks. Running the test suite with debug mode enabled and a threshold low enough to catch synchronous I/O — a few tens of milliseconds — turns a blocking call into a failing test. The warning can be escalated to an error with a logging filter, so a pull request that introduces requests.get inside a coroutine fails its checks rather than an on-call engineer's evening.

Review new dependencies for their I/O model. A library that performs network or file I/O either offers an async interface or it does not. Asking the question when the dependency is added is cheaper than discovering the answer from a watchdog stack. Where only a synchronous client exists, wrapping it once in a thin async adapter that uses a thread — and forbidding direct use — keeps the fix in one place.

Treat logging configuration as part of the loop's contract. Every handler attached to the root logger runs on whatever thread logs, and in an async service that is the loop thread. A handler added for a new destination — a network sink, a slow file system, a third-party service — becomes a blocking call on every log statement in the process. Routing all handlers through a queue, so the loop only ever enqueues, removes this category permanently.

Common blockers and their replacements A table of calls that commonly block an asyncio event loop in Python services and what to use instead. requests.get inside a coroutine blocks for the whole HTTP round trip; use an async client such as httpx.AsyncClient. time.sleep blocks the loop; use asyncio.sleep. A synchronous database driver such as psycopg2 blocks per query; use an async driver or run the call with asyncio.to_thread. json.loads on a multi-megabyte payload holds the loop for the parse; move it to a thread or process. Password hashing with bcrypt blocks for tens of milliseconds by design; run it in a thread. The note says the loop can only run one thing at a time, so any call that waits or computes for long stalls every request. blocking call instead requests.get(...) httpx.AsyncClient time.sleep(...) await asyncio.sleep(...) psycopg2 query async driver, or asyncio.to_thread json.loads on megabytes to_thread, or a process pool bcrypt hashing asyncio.to_thread the loop runs one thing at a time — anything that waits or computes for long stalls every request
Most loop stalls come from a short list of calls. Each has an async replacement or belongs on a thread.

Configuration options

Tool Where Cost Answers
Loop lag histogram production negligible whether the loop is blocked
CPU utilisation production none blocked or busy
Debug mode + slow_callback_duration staging significant which task, by name
Watchdog thread production negligible when healthy the exact call, with a stack
Wall-clock profile on demand 1–2% where the loop thread waits
asyncio.to_thread the fix a thread per blocking call frees the loop

Verification

The fix is verified by the lag distribution, not by the absence of warnings.

histogram_quantile(0.99, rate(event_loop_lag_seconds_bucket{service="checkout"}[10m]))

Expected Output: the tail falling back to a few milliseconds after the change.

before  event_loop_lag p99  0.291 s
after   event_loop_lag p99  0.004 s

A tail that improves but does not return to single-digit milliseconds means a second blocking call remains; the watchdog's next warning will name it.

Common mistakes

Investigating the endpoints that spiked. Error signature: hours spent on handlers that turn out to be fine. Root cause: they are victims of a stall elsewhere. Remediation: look for the call holding the loop, which the watchdog names.

Using an on-CPU profile. Error signature: an idle-looking profile of a slow service. Root cause: the loop thread is blocked, not executing. Remediation: capture a stack during the stall, or profile wall-clock.

Leaving debug mode on in production. Error signature: a service slower after the diagnostic change than before. Root cause: debug mode instruments every callback. Remediation: use it in staging, and the watchdog in production.

Capturing the stack after the stall. Error signature: a stack that shows healthy code. Root cause: the capture ran on the loop thread once it was free. Remediation: capture from a separate thread while the loop is still held.

Fixing one call and removing the watchdog. Error signature: the same symptom three months later from a different call. Root cause: blocking calls are reintroduced constantly. Remediation: keep the watchdog installed permanently.

Frequently Asked Questions

How do I know the event loop is blocked rather than just busy?

CPU utilisation. A loop doing genuine computation shows high CPU alongside high lag; a loop blocked on a synchronous I/O call shows high lag with low CPU, because the thread is waiting rather than working. The latter is far more common.

Is asyncio debug mode safe in production?

It adds overhead to every callback and task, which is acceptable in staging and generally unwelcome in production. The watchdog approach in this page gives the same answer in production at negligible cost, by capturing a stack only when lag actually crosses a threshold.

What usually blocks an event loop?

A synchronous database driver or HTTP client called from a coroutine, a logging handler writing synchronously to a slow sink, a large JSON encode or decode, a blocking DNS lookup, or a CPU-heavy computation such as templating or compression. The first two account for most incidents.

Why does one slow call affect unrelated endpoints?

Because every coroutine in the process shares the single loop thread. While one call holds it, no other coroutine can run, so requests to completely different endpoints wait for the same stall. That shared impact is the defining symptom.