asyncio.to_thread(fn, *args), added in Python 3.9, is the supported way to call blocking code from a coroutine. It is a thin wrapper over loop.run_in_executor(None, ...), so every call shares one default ThreadPoolExecutor for the loop, whose size is bounded by the ThreadPoolExecutor default of the CPU count plus four, capped at 32 worker threads.
That sharing matters. A handful of long-running blocking calls saturate the pool, and every subsequent to_thread queues behind them, including ones from unrelated parts of the application. The symptom is latency that grows without any single component looking slow.
For a known category of blocking work, create a dedicated ThreadPoolExecutor with an explicit max_workers and pass it to loop.run_in_executor so it cannot starve the shared pool. to_thread propagates contextvars to the worker, which run_in_executor does not do on its own. Neither helps with CPU-bound work in a single process, since the GIL serialises it; use ProcessPoolExecutor there.