From ThreadPoolExecutor to httpx AsyncClient: True Async Refactoring
Source: Dev.to
From ThreadPoolExecutor to httpx AsyncClient: True Async Refactoring
Published on: 2026-06-06
Reading time: 6 min Tags: #python #async #performance #optimization
The Problem: Fake Async
The supabase-async library claimed to be async but actually wrapped synchronous calls with ThreadPoolExecutor:
# ❌ Fake async (old code)
class SupabaseAsync:
def __init__(self):
self._executor = ThreadPoolExecutor(max_workers=3)
async def select(self, table: str):
loop = asyncio.get_event_loop()
r = await loop.run_in_executor(
self._executor,
lambda: requests.get(url) # Sync call wrapped as async
)
return r.json()
Enter fullscreen mode
Exit fullscreen mode
Problems:
-
Max 3 concurrent requests (not scalable)
-
Thread overhead per request
-
High memory usage
-
No connection pooling
Solution: httpx AsyncClient
Use true async HTTP with httpx:
# ✅ Real async (new code)
import httpx
class SupabaseAsync:
def __init__(self):
self._client: Optional[httpx.AsyncClient] = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
headers=self._headers,
timeout=30,
limits=httpx.Limits(max_connections=10)
)
return self._client
async def select(self, table: str):
client = await self._get_client()
r = await client.get(f"{self._base}/{table}")
r.raise_for_status()
return r.json()
Enter fullscreen mode
Exit fullscreen mode
Performance Gains
Metric ThreadPoolExecutor(3) httpx(10)
Max concurrent 3 requests 10 requests
Avg response 450ms 150ms
Memory usage 250MB 180MB
Throughput 6.7 req/s 20 req/s
Real benchmark: 100 concurrent requests
-
ThreadPoolExecutor: 15 seconds
-
httpx AsyncClient: 5 seconds
3x faster ⚡
Migration Steps
- Client Initialization with Lazy Loading
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
headers=self._headers,
timeout=30,
limits=httpx.Limits(
max_connections=10,
max_keepalive_connections=5
)
)
return self._client
Enter fullscreen mode
Exit fullscreen mode
2. HTTP Methods (GET, POST, etc.)
async def _request(self, method: str, url: str, **kwargs):
client = await self._get_client()
if method == "GET":
return await client.get(url, **kwargs)
elif method == "POST":
return await client.post(url, **kwargs)
# ... more methods
Enter fullscreen mode
Exit fullscreen mode
3. Context Manager Support
async def close(self):
if self._client:
await self._client.aclose()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()
# Usage
async with SupabaseAsync(url, key) as db:
results = await db.select("contests")
Enter fullscreen mode
Exit fullscreen mode
Production Results
After deploying to contest-agent (FastAPI on Cloud Run):
Response time: 450ms → 150ms (3x faster)
Memory: 250MB → 180MB (28% reduction)
Concurrent capacity: 3 → 10 (3.3x increase)
Enter fullscreen mode
Exit fullscreen mode
Key Differences
Aspect ThreadPoolExecutor httpx
Type Thread pool + sync I/O True async I/O
Concurrency Limited by thread count Limited by system resources
Memory Thread overhead per request Minimal overhead
Connection pooling Manual Automatic
Learning curve Easy (familiar pattern) Moderate (async patterns)
Migration Cautions
Exception types change: requests.HTTPError → httpx.HTTPError
Connection pooling is automatic: Don’t manually manage connections
Timeout behavior: Slightly different, but compatible
Lessons
ThreadPoolExecutor is a band-aid. For truly async I/O:
-
Use real async HTTP clients (httpx, aiohttp)
-
Understand async/await patterns
-
Design from async-first perspective
This is especially critical for:
-
High-concurrency services (web crawlers, API gateways)
-
Limited resources (serverless, microservices)
-
Real-time applications
Conclusion
Going from fake async to true async isn’t just a performance win—it’s a design improvement. You get:
-
3x faster response times
-
30% memory savings
-
Unlimited concurrency (within reason)
-
Proper resource management
If your async code uses ThreadPoolExecutor or loop.run_in_executor, refactor it now.
