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:

  1. Does the client have a multi-key or batch operation? Use it.
  2. Does the client support protocol-level pipelining? Use it.
  3. Does the HTTP client support connection pooling with keep-alive? Rely on native client limits.
  4. 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.