Asynchronous APIs are designed to handle many concurrent requests efficiently by avoiding thread blocking I/O operations (such as database queries, network calls, or file access). Instead of assigning one worker per request, async APIs use an event loop to switch between tasks, allowing better resource utilization under high concurrency.
In this article we will compare the performance of a sync django view running with a gunicorn server and an async django view running with an async uvicorn server. In both cases we call a test api and although it is expected for the async view running in a event loop and not being constrained by CPU threads to be significaltly faster, the results show the opposite.
To test the performance, we use hey load testing tool, which can be installed on Linux systems using:
sudo apt install hey
Defining Views
We define two views, sync_view and an async_view, both calling the same external API endpoint.
import requests
from django.http import JsonResponse
import httpx
def sync_view(request):
r = requests.get("https://jsonplaceholder.typicode.com/todos/1")
return JsonResponse(r.json())
async def async_view(request):
async with httpx.AsyncClient() as client:
r = await client.get("https://jsonplaceholder.typicode.com/todos/1")
return JsonResponse(r.json())
The routes are:
/sync/test → sync view/async/test → async view
and the corresponding URL mappings are:
path('async/test', async_view, name="async-test"),
path('sync/test', sync_view, name="sync-test"),
Load Testing Setup
The tests were run inside Docker containers using:
- Gunicorn for the sync view
- Uvicorn for the async view
Sync View Testing
To test the sync view performance (sync/test). We first run the Django server with Gunicorn with 4 workers and 10 threads per worker, This means simutanously 40 requests can be served.
gunicorn project.wsgi:application --workers 4 --threads 10 --bind 0.0.0.0:8000
We send 150 requests 30 of them being concurrent, 300 requests with 50 concurrency and 1000 requests with 100 concurency and log the results.
hey -n 150 -c 30 http://localhost:8000/sync/testhey -n 300 -c 50 http://localhost:8000/sync/testhey -n 1000 -c 100 http://localhost:8000/sync/test
Async View Testing
To test the async view performance (async/test), we run the Django server with uvicorn.
uvicorn project.asgi:application --workers 4 --host 0.0.0.0 --port 8000
hey -n 150 -c 30 http://localhost:8000/async/testhey -n 300 -c 50 http://localhost:8000/async/testhey -n 1000 -c 100 http://localhost:8000/async/test
Terminology
-
Requests Per Second (RPS) measures the number of requests a server is able to handle and complete per second under a given load.
-
Average latency shows how long a request takes on average.
-
P95 latency means that 95% of requests completed faster than a given value (for example, 1.22s).
Results
Testing results are presented in the table bellow.
| Test | Mode | RPS | Avg latency | P95 latency |
|---|---|---|---|---|
| 150 / 30 | Sync | 41.3 | 0.45s | 1.22s |
| Async | 35.0 | 0.69s | 1.52s | |
| 300 / 50 | Sync | 86.2 | 0.46s | 0.93s |
| Async | 38.1 | 0.94s | 1.69s | |
| 1000 / 100 | Sync | 81.4 | 0.92s | 1.88s |
| Async | 42.0 | 1.75s | 3.04s |
The results show that sync view consistently outperforms the async view, as the the sync view has higher RPS, lower average latency and better tail latency.
Takeaway
Async view does not automatically imply faster performance, and for single outbound API calls at moderate load, sync views can often be faster, simpler, and more predictable.
In this case, the sync setup performs well because Gunicorn provides enough threads to efficiently wait for I/O without overwhelming the system.