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 () 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.