Debugging a slow API: blocked loops, sequential awaits and throwaway clients
A slow API has three usual suspects that no profiler of your own code shows at first: something stops the event loop, independent waits run one after another, or every call pays for setup it should have paid once. The skill this section trains is turning "it feels slow" into a number that points at one of them.
A blocking call stops everyone
An async def handler shares one thread with every other request of the process. A synchronous call that waits (time.sleep, requests, a legacy client, heavy CPU work) holds that thread, so unrelated requests, even /healthz, wait behind it. The test is to time a cheap endpoint while the suspect endpoint runs. The cure is to move the call off the loop (run_in_threadpool, asyncio.to_thread) or to use an async client. Adding await in front of a synchronous function, a lock around it, or asyncio.wait_for does not make it yield.
Independent waits should overlap
When a handler needs two things that do not depend on each other (two remote services, two queries on separate sessions), await a(); await b() costs the sum of both. asyncio.gather(a(), b()) starts both and costs the longest. Measure each dependency alone, then the endpoint: a total close to the sum is the signature. Calls that need each other's result, or that share one AsyncSession, must stay sequential.
Build the client once
An httpx.AsyncClient owns a connection pool with keep-alive. Creating one per call and closing it at the end of the block throws the pool away, so every call pays for a new TCP (and in production TLS) handshake and leaves a socket in TIME_WAIT. On localhost this is invisible, which is why it survives review. Create the client once at startup, share it, and close it at shutdown. Counting how often the client is constructed is a better test than timing.