Django Async ORM Under High Concurrency: ThreadPoolExecutor Starvation, sync_to_async Traps, and ASGI Worker Sizing

Mixing async Django views with ORM calls or blocking SDKs can silently starve ThreadPoolExecutors and lock the ASGI event loop. Learn how to calibrate async boundaries, size ASGI_THREADS, and avoid 504 timeouts.

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.

All Insights
Chat on WhatsApp