The Illusion of Async in Django
Since the introduction of asynchronous views and the async ORM interface in Django 4.1+, developers have increasingly transitioned high-throughput API endpoints to async def views running under ASGI servers like Uvicorn or Daphne. The promise is enticing: handle thousands of concurrent WebSockets, external API integrations, and streamed responses on a fraction of the hardware required by synchronous WSGI workers.
However, under heavy production loads (3,000+ requests/second), teams frequently encounter catastrophic performance degradation: response times spike from 25ms to 8 seconds, Uvicorn event loops become unresponsive, and reverse proxies emit waves of 504 Gateway Timeouts. The root cause is almost always ThreadPoolExecutor Starvation caused by improper async-to-sync boundaries.
The Thread Pool Bottleneck Behind sync_to_async
While Django exposes async methods like aget(), afilter(), and acount(), the underlying database drivers (such as psycopg2 or psycopg3 in standard mode) remain synchronous blocking C extensions. To bridge this gap without blocking the main asyncio event loop, Django executes every ORM query inside an internal thread pool managed by asgiref.sync.sync_to_async.
By default, asgiref caps this thread pool at 40 threads (or min(32, os.cpu_count() + 4) depending on version). If an async view performs 4 sequential ORM calls, and 20 concurrent requests arrive simultaneously:
# DANGEROUS PATTERN in high-concurrency async view
async def checkout_view(request):
user = await User.objects.aget(id=request.user_id) # Consumes 1 thread
cart = await Cart.objects.aget(user=user) # Consumes 1 thread
inventory = await Inventory.objects.filter(cart=cart).aexists() # Consumes 1 thread
...
Under a burst of 100 concurrent requests, all 40 threads in the executor become blocked waiting on database I/O. Subsequent requests queue in memory. As the queue grows, the main event loop stalls waiting for threads to free up, and the entire ASGI process grinds to a halt.
Architectural Fixes for High-Throughput ASGI
1. Bounding and Sizing the Thread Pool
Set the environment variable ASGI_THREADS to match your database connection pool and CPU capabilities. If your PostgreSQL database connection pool accommodates 100 connections per container, align your thread pool accordingly:
# In production container startup script or systemd
export ASGI_THREADS=120
2. Consolidating Sync Operations
Rather than jumping across the async-to-sync context boundary 5 times in a single request, execute all related database operations inside a single synchronous function wrapped in sync_to_async:
from asgiref.sync import sync_to_async
def _fetch_checkout_data_sync(user_id):
# Executes inside a single worker thread without context thrashing.
user = User.objects.select_related('profile').get(id=user_id)
cart = Cart.objects.prefetch_related('items__product').get(user=user)
has_inventory = Inventory.objects.filter(cart=cart).exists()
return user, cart, has_inventory
fetch_checkout_data = sync_to_async(_fetch_checkout_data_sync, thread_sensitive=True)
async def checkout_view(request):
# Exactly one thread context switch for the entire query sequence
user, cart, has_inventory = await fetch_checkout_data(request.user_id)
...
3. Beware of thread_sensitive Traps
By default, sync_to_async(..., thread_sensitive=True) executes all calls on a single shared thread to ensure thread-local compatibility with older Django middleware. Under heavy async concurrency, this single thread becomes a massive serialization bottleneck. For pure database queries, verify that your models do not rely on thread-local state and evaluate using custom thread pools.
For more architectural patterns on database query efficiency, check our guide on Defending Against N+1 Queries in Django.