Course outline

FastAPI / Intermediate: application integration and security / Lesson 7

Async execution and blocking work

Choose async and synchronous boundaries according to the called operation, and preserve event-loop responsiveness without claiming unlimited parallelism.

About 5 min readContent reviewed
On this page

Lesson goals

  • Explain the assumptions and boundaries of async execution and blocking work.
  • Interpret both complete examples and justify their observed outputs.
  • Diagnose the listed failures and apply the worked solutions while keeping identity, state, and lifecycle ownership explicit.

Async is a cooperative execution model

An async function can yield while awaiting a compatible operation so other work can proceed. It does not automatically move every statement to another thread or make CPU-heavy work parallel. A blocking library call inside an async endpoint can stall that event loop even though the function is spelled async def. The first example awaits a controlled in-memory coroutine and returns its result through an isolated client. It demonstrates syntax and ownership, not production throughput. Select the execution model from the dependency’s actual API and cost rather than adding async to every function by habit.

python Complete awaited-operation check
import asyncio
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
async def calculate(value):
    await asyncio.sleep(0)
    return value * 2
@app.get("/double/{value}")
async def double(value: int):
    return {"value": await calculate(value)}
with TestClient(app) as client:
    print(client.get("/double/3").json())
Expected output
{'value': 6}

The coroutine yields cooperatively and returns six. sleep(0) is a controlled scheduling point, not an external wait or a performance benchmark.

Know which calls FastAPI dispatches

FastAPI can run ordinary synchronous path operations and synchronous dependencies in a thread pool under its documented behavior. A normal helper called directly from your async function is still your direct call; the framework does not intercept every helper and offload it automatically. The second example explicitly uses Starlette’s run_in_threadpool and verifies that the helper runs outside the endpoint’s event-loop thread. That proves the chosen boundary, not that the operation is free or safely parallel. Thread capacity is shared and bounded, and database drivers or other consumers may contend for the same resources.

python Complete explicit thread-boundary check
import threading
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.concurrency import run_in_threadpool
app = FastAPI()
@app.get("/offload")
async def offload():
    loop_thread = threading.get_ident()
    different = await run_in_threadpool(lambda: threading.get_ident() != loop_thread)
    return {"offloaded": different}
with TestClient(app) as client:
    print(client.get("/offload").json())
Expected output
{'offloaded': True}

True confirms this helper executed on another thread. The sample performs no blocking I/O and makes no throughput or thread-safety claim about real clients.

Match clients and resource ownership

Use an async-capable client with await when its API supports that model, or a deliberate synchronous boundary when using blocking code. Do not call asyncio.run from inside a running request event loop. A client or connection pool also needs lifetime and cleanup ownership; creating one for every tiny call can waste resources, while sharing one unsafe mutable connection can cause races. Later lessons use lifespan and dependency boundaries for those decisions. Pass deadlines and cancellation through supporting layers, and remember that abandoning a wait does not prove a remote operation stopped or an external write was undone.

Bound concurrency before scaling

Starting many coroutines can overload a database, upstream service, memory budget, or connection pool. A semaphore or queue can bound admitted work, but the waiting queue and overall deadline also need limits. CPU-heavy Python may need a different process or worker design; a thread pool is not a universal speedup under every interpreter and workload. Do not generate real traffic to prove a lesson’s claim. Use controlled local operations to verify scheduling and failure ownership, then measure representative production characteristics only in an authorized environment with explicit capacity and stop conditions.

Diagnose responsiveness with the right evidence

Separate event-loop blocking from pool waiting, slow queries, network time, serialization, and overload. A fast unit test or a function labeled async cannot establish end-to-end latency. Preserve safe stage timing and request context and avoid logging payloads or credentials. Both examples use synthetic values, no real sleeping workload, no external client, and no network listener. Their output is deterministic for the tested baseline. The important guarantee is that completion and failure remain observed and resources have an owner; performance and resilience still require application-specific evidence rather than a keyword or decorator.

Common errors and fixes

An async endpoint blocks on a synchronous helper

Why it happens
Direct helper calls were assumed automatically offloaded.
How to fix it
Use a compatible async API or an explicit reviewed sync boundary.
Prevent it next time
Review the whole call chain, not only the endpoint declaration.

More coroutines exhaust a dependency

Why it happens
Admission and downstream budgets were absent.
How to fix it
Bound active work, waiting, and total deadlines.
Prevent it next time
Measure pool and dependency pressure separately.

A thread pool is promised as CPU parallelism

Why it happens
Execution model and workload costs were not distinguished.
How to fix it
Profile and choose an appropriate worker architecture.
Prevent it next time
State interpreter and workload assumptions in performance claims.

Check your understanding

1. A sync database driver is called directly inside async def. What should be reviewed?

Show worked solution

Determine whether the call blocks and whether a compatible async driver or deliberate thread-based boundary is appropriate. Also review connection ownership and pool limits. Merely adding await to a nonawaitable call or renaming the function cannot solve the execution mismatch.

2. Why can a semaphore fail to bound total memory?

Show worked solution

It may limit active work while an unbounded number of tasks or payloads wait outside it. Bound admission, queue length, item size, and deadline as well. The resource protected by each limit must be explicit.

Key takeaways

  • An async function can yield while awaiting a compatible operation so other work can proceed.
  • Use an async-capable client with await when its API supports that model, or a deliberate synchronous boundary when using blocking code.
  • Separate event-loop blocking from pool waiting, slow queries, network time, serialization, and overload.

Official references