DEV Community

JustJinoIT
JustJinoIT

Posted on

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:

  1. Max 3 concurrent requests (not scalable)
  2. Thread overhead per request
  3. High memory usage
  4. 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

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
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

  1. Exception types change: requests.HTTPErrorhttpx.HTTPError
  2. Connection pooling is automatic: Don't manually manage connections
  3. 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)