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()
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()
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
1. 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
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
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")
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)
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.
Top comments (0)