Code philosophy
Native concurrency first
13 October 2026 · 6 min read
The Default Reflex: Premature asyncio.gather
When developers first discover Python’s asyncio, their immediate reflex for executing multiple independent tasks is to reach for asyncio.gather:
results = await asyncio.gather(
client.get("key:1"),
client.get("key:2"),
client.get("key:3"),
client.get("key:4"),
)
While asyncio.gather is a fundamental primitive, relying on it blindly across external service integrations is an engineering trap. When you write this code against a database or network client, you are manually managing application-level concurrency for an operation that the underlying protocol can handle far more efficiently natively.
Before hand-rolling coroutine pools, follow this decision hierarchy:
- Does the client have a multi-key or batch operation? Use it.
- Does the client support protocol-level pipelining? Use it.
- Does the HTTP client support connection pooling with keep-alive? Rely on native client limits.
- Only when native protocol options are exhausted: Implement application-level concurrency with bounded semaphores.
Case Study: Redis Pipelines vs asyncio.gather
In high-throughput services, performing ten consecutive GET queries using asyncio.gather fires ten separate TCP packets, incurs ten round-trip network delays, and triggers ten protocol serializations:
# Inefficient: 10 network round-trips
values = await asyncio.gather(*(redis.get(f"user:{uid}") for uid in user_ids))
Redis natively supports pipelining. A pipeline bundles all commands together in a single write operation, transmits them across the wire in a single TCP socket packet, and processes them sequentially inside the Redis engine:
# Highly efficient: 1 network round-trip
pipe = redis.pipeline()
for uid in user_ids:
pipe.get(f"user:{uid}")
values = await pipe.execute()
In microbenchmark tests on a local 1Gbps network, replacing asyncio.gather with a Redis pipeline reduced latency by 85% and slashed CPU overhead on both the web server and the database instance.
Controlled HTTP Concurrency: Trusting Client Connection Pools
Another classic mistake occurs in web crawling and API data aggregation: spawning 200 unconstrained httpx.get coroutines using gather. This instantly overloads socket descriptors, triggers DNS resolution throttles, and provokes 429 rate-limiting responses from target hosts.
Instead of writing custom asyncio.Semaphore locks around every call, configure native connection pool limits inside httpx.AsyncClient:
import httpx
# Concurrency constraints enforced automatically at the transport layer
limits = httpx.Limits(
max_connections=20,
max_keepalive_connections=10,
keepalive_expiry=30.0,
)
async with httpx.AsyncClient(limits=limits, timeout=10.0) as client:
# Requests naturally queue and reuse existing TCP sockets cleanly
responses = await asyncio.gather(*(client.get(url) for url in target_urls))
The Takeaway
Always seek protocol-native concurrency before writing application-level scheduling logic. Pipelining, batch commands, and driver connection pools will always outperform custom coroutine orchestration in speed, predictability, and memory footprint.