This section's final lesson brings the pieces together: httpx's async support, asyncio's concurrency model, and TaskGroup's structured error handling, applied specifically to the extremely common real-world need of making many HTTP requests concurrently instead of one at a time.
1Sequential Requests Waste the Overwhelming Majority of Wall-Clock Time
fetch_all_sequential processes 100 URLs strictly one after another ā each client.get(url) call blocks until that specific request completes before the loop moves to the next iteration. Since network I/O is fundamentally about *waiting* (for the request to travel to the server, for the server to process it, for the response to travel back), and each individual request spends the vast majority of its ~200ms duration simply waiting rather than doing any actual computation, processing 100 such requests sequentially means 100 separate, non-overlapping waiting periods stacked one after another ā 20 seconds of nearly pure waiting, done one request at a time when there was no fundamental reason it needed to be.
This is precisely the I/O-bound scenario the Concurrency & Parallelism section identified as ideal for asyncio's cooperative concurrency model: each individual await client.get(url) yields control back to the event loop exactly while it's waiting for the network response, which means the event loop is free to work on *other* requests during that exact same waiting window, rather than sitting idle.
asyncio.gather(*(client.get(url) for url in urls)) starts every request's coroutine essentially simultaneously, and because each one spends its time waiting (not computing), their waiting periods overlap almost entirely ā the total elapsed time for all 100 requests approaches the duration of the single *slowest* request, not the sum of all 100, which is the exact same 'overlapping wait times' principle demonstrated with the simpler fetch("A")/fetch("B")/fetch("C") example back in the asyncio fundamentals lesson, now applied at real, practical scale.
import httpx
def fetch_all_sequential(urls: list[str]) -> list[dict]:
results = []
with httpx.Client(timeout=10) as client:
for url in urls:
response = client.get(url)
results.append(response.json())
return results
# 100 URLs at 200ms each = 20 SECONDS total, even though each one is just waitingConcurrent: ~200ms (overlapping waits, bounded by the slowest)
2Unlimited Concurrency Is Its Own Problem: Being a Considerate Client
Firing off asyncio.gather() over genuinely large numbers of URLs ā hundreds or thousands ā with no limit on how many run truly simultaneously creates a real risk: the target server (or servers, if requests span multiple hosts) suddenly receives an enormous burst of simultaneous requests, which can overwhelm it, trigger its rate limiting (resulting in many requests failing with 429 Too Many Requests), or, in an extreme case, constitute an accidental self-inflicted denial-of-service against a system you don't control and didn't intend to stress-test.
asyncio.Semaphore(max_concurrent) is the standard tool for capping concurrency deliberately: it's a counter-based synchronization primitive that allows at most max_concurrent coroutines to be inside its async with semaphore: block simultaneously ā any additional coroutine attempting to enter waits until one of the currently-running ones exits and releases a slot. Wrapping each individual fetch_one call in async with semaphore: means all 100 URLs are still processed concurrently *up to* the semaphore's limit, but never more than max_concurrent requests are genuinely in flight to the target server at any single instant.
Choosing an appropriate max_concurrent value is a judgment call specific to the target API ā some APIs document explicit rate limits (10 requests per second, for instance) that directly inform the right concurrency cap; for undocumented internal or third-party APIs, a conservative starting value (10-20) tested and adjusted based on observed behavior (error rates, response times) is the practical approach, balancing genuine speedup against being a considerate, well-behaved API consumer.
import asyncio
import httpx
async def fetch_all_concurrent(urls: list[str]) -> list[dict]:
async with httpx.AsyncClient(timeout=10) as client:
responses = await asyncio.gather(*(client.get(url) for url in urls))
return [r.json() for r in responses]
# 100 URLs at 200ms each = roughly 200ms total, not 20 secondsConcurrent, but capped ā never overwhelms the target server
3Combining With TaskGroup for Correct Failure Handling at Scale
Firing off 100 concurrent requests raises the exact question the Task Groups lesson addressed directly: what happens if one of them fails? Plain asyncio.gather(), by default, propagates the first exception it encounters but does *not* automatically cancel the other 99 still-in-flight requests ā they continue running independently in the background, exactly the orphaned-task gap that lesson covered, now scaled up to genuinely consequential proportions with a large batch of concurrent requests.
Combining the concurrency and semaphore-limiting patterns from this lesson with asyncio.TaskGroup (instead of gather) closes that gap at scale: if any one of the 100 requests fails with an unhandled exception, every other still-running request in the group is automatically cancelled, avoiding the wasted work (and continued, unnecessary load on the target server) of finishing 99 requests whose results will ultimately be discarded once the batch operation as a whole has already failed.
This lesson's combination ā httpx.AsyncClient for the actual async HTTP capability, asyncio.gather()/TaskGroup for concurrent scheduling, and asyncio.Semaphore for responsible rate-limiting ā represents the complete, production-grade pattern for making many concurrent HTTP requests correctly: fast (genuine concurrency, not sequential waiting), considerate (bounded, not overwhelming the target), and correct (proper failure handling, not silently orphaning work on partial failure) ā bringing together nearly every concept this Networking section and the Concurrency & Parallelism section have covered, applied to one of the most common real-world professional Python tasks.
import asyncio
import httpx
async def fetch_all_limited(urls: list[str], max_concurrent: int = 10) -> list[dict]:
semaphore = asyncio.Semaphore(max_concurrent)
async def fetch_one(client, url):
async with semaphore: # at most max_concurrent run at once
response = await client.get(url)
return response.json()
async with httpx.AsyncClient(timeout=10) as client:
return await asyncio.gather(*(fetch_one(client, url) for url in urls))Fast, considerate, and correct ā the complete pattern
4Step-by-Step Breakdown
Fetching 100 URLs one at a time, sequentially, wastes almost all of the time waiting on network I/O doing nothing. Concurrent async requests fix that ā but need real discipline around how many happen at once.
Fetching URLs one at a time sequentially wastes almost all the time -- each request spends most of its duration just WAITING on network I/O.
asyncio.gather() with httpx.AsyncClient runs every request CONCURRENTLY -- total time approaches the SLOWEST single request, not the sum of all of them.
Checkpoint: Why does fetch_all_concurrent take roughly 200ms for 100 URLs, while fetch_all_sequential takes roughly 20 seconds?
- āAll 100 requests' waiting time overlaps concurrently, so total time approaches the slowest single request instead of the sum of all 100
- āasyncio somehow makes the underlying network requests themselves transmit data faster
An UNLIMITED number of concurrent requests can overwhelm the server you're calling -- a Semaphore caps how many run at once, staying a good API citizen.
Checkpoint: What problem does asyncio.Semaphore(max_concurrent) solve that plain asyncio.gather() over all 100 URLs at once does not?
- āIt caps how many requests run simultaneously, avoiding overwhelming the target server (or exceeding its rate limits) with all 100 at once
- āIt makes the overall batch of requests complete measurably faster than unlimited concurrency
That completes Networking ā requests, httpx, REST clients, WebSockets, and now concurrent async requests. Next, Python Testing covers verifying all of this code actually works correctly.
Estimate Real Concurrent Speedup. Finish estimated_total_time(): concurrent requests overlap their wait time instead of summing it.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Use asyncio with httpx.AsyncClient for any batch of more than a handful of independent HTTP requests
Sequential requests waste the overwhelming majority of wall-clock time on pure waiting that concurrent requests overlap almost entirely ā a substantial, easily-achieved speedup for genuinely independent requests.
Always cap concurrency with asyncio.Semaphore when making a large number of concurrent requests
Unbounded concurrency risks overwhelming the target server, triggering rate limits, or behaving as an accidental denial-of-service ā a semaphore keeps concurrent requests fast while remaining a considerate API consumer.
Frequent Bugs
Firing off asyncio.gather() over hundreds of URLs with no concurrency limit, overwhelming the target server, triggering rate limiting, and causing many requests to fail with 429 errors that a bounded, considerate approach would have avoided.
Wrap concurrent requests in an asyncio.Semaphore with an appropriate max_concurrent value, keeping the genuine speedup of concurrency while staying within a reasonable, considerate request rate for the target server.
Real-World Examples
Concurrently Validating a Large List of URLs
A link-checking tool needs to verify that 500 URLs from a website's sitemap are all still valid (return a 200 status), as quickly as possible without overwhelming the site being checked.
import asyncio
import httpx
async def check_urls(urls: list[str], max_concurrent: int = 20) -> dict[str, int]:
semaphore = asyncio.Semaphore(max_concurrent)
results = {}
async def check_one(client, url):
async with semaphore:
try:
response = await client.get(url, timeout=10)
results[url] = response.status_code
except httpx.RequestError:
results[url] = 0
async with httpx.AsyncClient() as client:
async with asyncio.TaskGroup() as tg:
for url in urls:
tg.create_task(check_one(client, url))
return results