šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.
HTML MASTER CLASS /// LEARN TAGS /// BUILD STRUCTURE /// SEMANTIC WEB /// HTML MASTER CLASS /// LEARN TAGS ///

Concurrent Async HTTP Requests in Python

Combine httpx.AsyncClient with asyncio.gather/TaskGroup to fire off dozens or hundreds of HTTP requests concurrently — and the connection-limiting discipline that keeps it from overwhelming the server you're calling.

⚔ Total XP: 0|šŸ’» python XP: 0

Skill Matrix

UNLOCK NODES BY LEARNING NEW TAGS.

System Hub

Core logic.

Quick Quiz //

Why does fetch_all_concurrent take roughly 200ms for 100 URLs, while fetch_all_sequential takes roughly 20 seconds?


šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.

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 waiting
localhost:3000
Concurrent vs Sequential
Sequential: 20s (sum)
Concurrent: ~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 seconds
localhost:3000
Bounded Concurrency
asyncio.Semaphore(max_concurrent)
Concurrent, 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))
localhost:3000
Complete Production Pattern
AsyncClient + Semaphore + TaskGroup
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

ChromeSupported

Fully supported (via server-side Python execution).

FirefoxSupported

Fully supported (via server-side Python execution).

SafariSupported

Fully supported (via server-side Python execution).

EdgeSupported

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

THE BUG

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.

THE FIX

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

Interview Prep

?Frequently Asked Questions

Pascual Vila

Pascual Vila

Frontend Instructor // Code Syllabus

Common Pitfalls & Errors

The Error //

Using asyncio.gather() over a very large number of URLs with no concurrency limit, overwhelming the target server and causing many requests to fail with rate-limiting errors that a bounded approach would have avoided entirely.

# Risky: unbounded concurrency, can overwhelm the target server async with httpx.AsyncClient() as client: await asyncio.gather(*(client.get(url) for url in many_urls)) # Correct: bounded, considerate concurrency semaphore = asyncio.Semaphore(10) async def fetch_one(client, url): async with semaphore: return await client.get(url) async with httpx.AsyncClient() as client: await asyncio.gather(*(fetch_one(client, url) for url in many_urls))

The Solution //

Wrap each request in an asyncio.Semaphore-guarded block with an appropriate max_concurrent value, keeping genuine concurrency while staying within a considerate, sustainable request rate.

Lesson Glossary

[01]Concurrent HTTP requests

Multiple HTTP requests initiated and awaited together, overlapping their network wait times rather than running strictly one after another.

Code Preview
// Concurrent HTTP requests context

[02]asyncio.Semaphore

A synchronization primitive limiting how many coroutines can execute a given block of code simultaneously, used to bound request concurrency.

Code Preview
// asyncio.Semaphore context

[03]Rate limiting

A server-side mechanism restricting how many requests a client can make within a time window, often returning a 429 status when exceeded.

Code Preview
// Rate limiting context

[04]Considerate API client

A client designed to bound its own request concurrency/rate deliberately, avoiding overwhelming the server it depends on.

Code Preview
// Considerate API client context

Continue Learning