Python Lesson 33: Your Scraper Isn't Slow, It's Just Waiting
š Full written solution: https://interview-kit-fe.vercel.app/python-lesson-33-async-programming-with-asyncio š» Code on GitHub: https://github.com/timepasshub2539-hue/leetcode-python/tree/main/python-lesson-33-async-programming-with-asyncio Chapters: 0:00 The Waiting Trap 0:14 Why Scrapers Feel Slow 0:27 Threading: The Old Fix 0:42 One Thread, One Loop 1:03 async def and await 1:19 Await as a Checkpoint 1:30 100 Tasks at Once 1:45 Rewriting with Asyncio 2:20 The Numbers, Roughly 2:41 Where Async Doesn't Help 2:59 Waiting Was the Bottleneck Your code isn't slow, it's stuck waiting on I/O ā and threads aren't the fix you think they are. This lesson walks through async def, await as a checkpoint, and how a single event loop juggles 100 tasks without 100 threads. We rewrite a real scraper with asyncio and measure the actual speedup, then cover the honest limits: async doesn't help CPU-bound work, only the waiting kind. #python #asyncio #asyncawait #pythontutorial #coding Watch next: - Python Lesson 32: The GIL Is Lying to Your Threads: https://youtu.be/XYIfmqUO1dQ - The Problem: Longest Subsequence With Non-Zero Bitwise XOR #shorts: https://youtu.be/F9DwG4txKsU - Longest Subsequence With Non-Zero Bitwise XOR: https://youtu.be/sjFiZLkteYs
Introduction
Every developer eventually writes a loop that hits an API a hundred times and watches it crawl. The instinct is to blame the network, or blame Python, or reach for threads because "that's what concurrency means." None of those instincts are wrong exactly ā they're just aimed at the wrong problem.
The real issue in most I/O-heavy code is that the CPU is doing nothing at all. It fired off a request and is now just sitting there, waiting for bytes to come back over the wire. That distinction ā "slow" versus "waiting" ā is one of the more useful mental models you'll pick up as a Python developer, and it's also a favorite interview topic, because it separates people who've memorized async def syntax from people who understand what a coroutine is actually buying them.
This lesson walks through a real scraper: sequential requests, a naive multithreaded fix, and then the asyncio rewrite that gets the same speedup as threading without the memory cost. Along the way we'll cover why async has to propagate all the way up your call stack, what await really does under the hood, and where async falls apart completely ā because it's not a universal performance upgrade, it's a fix for one very specific kind of slowness.
Problem Overview
You have a list of 100 URLs. You need to fetch all of them and process the results. The naive approach is a for loop: request URL 1, wait for the response, request URL 2, wait for the response, and so on until all 100 are done.
Each individual request might take under a second. But because you're doing them one after another, the total time is roughly the sum of all of them ā somewhere around 100 seconds for 100 requests. The catch is that during each of those waits, your CPU isn't computing anything. It's idle. You're paying wall-clock time for work the processor isn't even doing.
The task: restructure this so the waiting overlaps instead of stacking up, without exploding memory usage or introducing race conditions.
Example
Say each request takes about 1 second to get a response.
Sequential:
Request 1 ā wait 1s ā Request 2 ā wait 1s ā ... ā Request 100 ā wait 1s
Total: ~100 seconds
Threaded (100 threads):
All 100 requests fired at roughly the same time, each on its own thread
Total: ~1-2 seconds, but 100 threads' worth of memory and OS scheduling overhead
Async (one loop, one thread):
All 100 requests kicked off as tasks, the loop switches between whichever isn't currently waiting
Total: ~1-2 seconds, no extra threads, no extra memory per request
The output speed of threading and async converges. The cost to get there does not.
Intuition
Here's how an engineer actually arrives at this solution rather than just memorizing it.
Start by asking: during a network request, what is the CPU doing? Nothing. It handed the request off to the OS's networking stack and now it's parked, waiting for a response to land in a buffer. That parked time is dead time ā the CPU could be doing something else, but in a simple sequential script, it isn't.
The threading instinct ā "give every request its own thread" ā is a reasonable next Stephen, because now the waits overlap. Thread 1 is parked waiting on request 1, thread 2 is parked waiting on request 2, and the OS switches between them. But each thread carries a real cost: a stack allocation, kernel scheduling overhead, context-switch cost. Fine for 100 requests. A disaster at 10,000.
The insight that gets you to asyncio is realizing you don't need 100 separate execution contexts ā you need one execution context that's smart enough to notice "this task is currently waiting, let me go run a different task while I wait." That's it. That's the entire idea. One thread, one loop, and a cooperative handoff every time a task would otherwise block.
The mechanism for that handoff is await. It's not "pause and do nothing" ā it's "I'm about to wait, so give control back to the loop and let it find something useful to do until I'm ready again."
Brute Force Solution
Idea: Fire requests one at a time, in a simple loop, waiting for each to complete before starting the next.
Algorithm:
- For each URL in the list, call the fetch function.
- Block until that fetch returns.
- Store the result.
- Move to the next URL.
Advantages:
- Trivially simple to write and reason about.
- No concurrency bugs possible ā everything happens in a strict, predictable order.
Disadvantages:
- Total time scales linearly with the number of requests, dominated entirely by network latency the CPU isn't even using.
- Doesn't scale past a handful of requests before runtime becomes unacceptable.
Complexity:
- Time: O(n Ć latency) ā n requests, each contributing its full round-trip time to the total.
- Space: O(1) beyond storing results ā no extra threads or tasks in flight.
Optimal Solution
The optimal approach uses asyncio to run all requests on a single thread, letting the event loop switch between tasks whenever one of them is waiting on I/O.
Three pieces make this work:
1. async def marks a pausable function. Any function that might need to hand control back to the loop mid-execution gets defined as a coroutine with async def.
2. await is the handoff point. When you await something, you're telling the loop "this is where I might need to wait ā feel free to run someone else until I'm ready." Because await can only be called inside an async def function, the "async-ness" of a call chain is contagious: if fetch is async, whatever calls fetch and awaits it must also be async, all the way up to main.
3. asyncio.gather starts everything up front, not one at a time. Instead of looping and waiting on each URL sequentially, you build a list of coroutine calls ā none running yet ā and hand the whole batch to gather. The loop then runs all of them concurrently, switching to whichever task isn't currently blocked on I/O.
| Approach | Threads used | Memory overhead | Time for 100 requests |
|---|---|---|---|
| Sequential | 1 | Minimal | ~100s |
| Threading | 100 | High (per-thread stack) | Single-digit seconds |
| asyncio | 1 | Minimal | Single-digit seconds |
Async gets you threading's speed without threading's memory bill, because there's only ever one thread ā the loop is just very good at not sitting idle.
Python Code
import asyncio
import aiohttp
import time
async def fetch(session: aiohttp.ClientSession, url: str) -> str:
"""Fetch a single URL and return its response text."""
async with session.get(url) as response:
return await response.text()
async def fetch_all(urls: list[str]) -> list[str]:
"""Fetch every URL concurrently and return results in order."""
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
async def main() -> None:
urls = [f"https://example.com/page/{i}" for i in range(100)]
start = time.perf_counter()
results = await fetch_all(urls)
elapsed = time.perf_counter() - start
succeeded = [r for r in results if not isinstance(r, Exception)]
failed = [r for r in results if isinstance(r, Exception)]
print(f"Fetched {len(succeeded)} pages in {elapsed:.2f}s")
if failed:
print(f"{len(failed)} requests failed: {failed[:3]}")
if __name__ == "__main__":
asyncio.run(main())
Code Walkthrough
fetch: Defined withasync defbecause it awaits a network call.session.get(url)returns an async context manager; entering it and reading.text()are both points where the coroutine yields control back to the loop.fetch_all: Builds a list of coroutine objects ā callingfetch(session, url)doesn't run anything yet, it just creates a coroutine.asyncio.gather(*tasks, ...)is what actually schedules and runs them concurrently.return_exceptions=True: Without this, the first exception raised by any task propagates immediately out ofgather, but the other tasks keep running in the background unless explicitly cancelled. With it, exceptions are captured as objects in the results list instead, so you can inspect successes and failures side by side.main: Alsoasync def, because it awaitsfetch_all. This is the propagation rule in practice ā you can't callawaitfrom a regular synchronous function.asyncio.run(main()): The single entry point that creates the event loop, runsmainto completion, and tears the loop down.
Dry Run
Take three URLs instead of a hundred, each taking roughly 1 second to respond.
fetch_allbuilds three coroutine objects āfetch(session, url1),fetch(session, url2),fetch(session, url3)ā none have run yet.asyncio.gatherschedules all three as tasks and starts running the first one.- Task 1 hits
await session.get(url1). This is a wait point, so it hands control back to the loop. - The loop starts task 2. Task 2 hits its own
awaitand also hands control back. - The loop starts task 3. Same thing ā it awaits and yields.
- Now all three are "in flight" waiting on the network simultaneously. As each response arrives, the loop resumes that specific task, finishes reading its response text, and marks it done.
- Once all three are complete,
gatherreturns a list of three results, in the same order the tasks were submitted ā not necessarily the order they finished in. - Total elapsed time: roughly 1 second, not 3, because the waits overlapped instead of stacking.
Complexity Analysis
- Time: O(max(latency) + processing) rather than O(n Ć latency). Since all requests are in flight concurrently, total wall-clock time is bounded by the slowest single request plus whatever CPU work is needed to process results ā not the sum of every request's latency. This holds as long as the event loop and connection pool aren't themselves saturated.
- Space: O(n) for the task list and buffered results, same as any approach that holds n items ā but crucially, no per-request thread stack. A thread typically reserves megabytes of stack space; a coroutine object is a few hundred bytes. That's the actual space win over threading, not asymptotic complexity but constant-factor memory cost.
- Why this is correct: the event loop guarantees that only one coroutine is executing Python bytecode at any instant ā there's no need for locks around shared state the way there is with threads, because a context switch only happens at
awaitpoints, which are visible in the code.
Alternative Solutions
Threading (concurrent.futures.ThreadPoolExecutor): Also achieves overlapping waits, and works fine for moderate request counts. Simpler to bolt onto existing synchronous code that uses requests instead of aiohttp. Loses to asyncio at scale because of per-thread memory and OS scheduling cost ā 10,000 threads will bring a machine down; 10,000 coroutines won't.
Multiprocessing: Wrong tool here entirely ā it solves CPU-bound problems by using multiple cores, but I/O-bound waiting doesn't need more cores, it needs better scheduling of the waiting itself.
asyncio.as_completed instead of gather: Useful when you want to process results as they arrive rather than waiting for the whole batch ā same underlying mechanism, different consumption pattern.
Edge Cases
- Empty URL list:
gather()with no tasks returns an empty list immediately ā no special-casing needed, but worth a test so a future refactor doesn't break it silently. - A single request times out:
gatherraises that exception at the point youawaitit (withoutreturn_exceptions=True), but does not cancel the other in-flight tasks ā they keep running unless you cancel them yourself. - All requests fail: With
return_exceptions=True, you get a list entirely composed of exception objects ā filter carefully before processing, since an unfiltered list will crash downstream code expecting strings. - Duplicate URLs: Handled the same as any other URL;
asynciodoesn't deduplicate for you, so add that logic yourself if it matters. - Extremely large URL list (10,000+): Un-bounded
gatherwill try to open that many connections at once; pair it with anasyncio.Semaphoreto cap concurrency and avoid overwhelming the target server or your own connection pool.
Common Mistakes
- Forgetting
mainneeds to beasynctoo.awaitonly works insideasync deffunctions ā the async-ness has to run all the way up the call chain. - Calling a coroutine without awaiting or scheduling it.
fetch(session, url)alone just creates a coroutine object; it does nothing until it's awaited or passed togather/create_task. - Looping and awaiting one at a time instead of batching with
gather. This defeats the entire purpose ā you're back to sequential waiting, just wrapped inasyncsyntax. - Using
asynciofor CPU-bound work. A tight numeric loop has noawaitpoints, so it blocks the entire loop ā every other task waits behind it. That's a job for threads (if I/O is also involved) or multiprocessing (if it's pure computation). - Not capping concurrency for very large batches. Firing off thousands of requests simultaneously without a semaphore can crash your own client or get you rate-limited by the server.
- Assuming
gathercancels sibling tasks on the first failure. It doesn't ā the exception is raised at the await point, but the other 99 tasks keep running in the background. - Mixing blocking calls (like
requests.get) inside async code. A synchronous blocking call inside anasync deffunction still blocks the whole event loop ā you need an async-native client likeaiohttp.
Interview Questions
- What's the difference between concurrency and parallelism, and where does
asynciofall? - Why doesn't
asynciohelp with CPU-bound work? - What happens if you call an
async deffunction withoutawaiting it? - How does the GIL relate to why threading doesn't help CPU-bound Python code either?
- What does
return_exceptions=Truechange aboutasyncio.gather's behavior? - How would you limit concurrency to, say, 20 simultaneous requests out of a list of 10,000?
- What's the difference between
asyncio.gatherandasyncio.create_task?
Similar Problems
- Design a rate-limited API client ā extends this pattern with a
Semaphoreto cap concurrent requests. - Producer-consumer with
asyncio.Queueā same event-loop model, different coordination pattern between tasks. - Implement a connection pool ā related to how
aiohttp.ClientSessionmanages reusable connections under the hood. - Timeout handling with
asyncio.wait_forā a natural follow-up to the "what if one request hangs" question raised here.
Key Takeaways
- I/O-bound slowness is waiting, not computing ā the CPU is idle the entire time.
- Threads fix the waiting problem too, but each one costs real memory and OS overhead.
asynciogets the same overlap as threading using a single thread, becauseawaitis a cooperative handoff, not a blocking wait.async defhas to propagate all the way up your call stack ā you can'tawaitfrom a regular function.asyncio.gatherstarts a batch of tasks together rather than looping and waiting one at a time; a failure in one task doesn't cancel the rest.- Async is not a general performance tool ā it does nothing for CPU-bound work, since there's no waiting to hand off during.
Watch the Video
This lesson is easier to absorb watching the scraper actually run ā sequential, threaded, and async, side by side with real timings. Watch it here: {LINK}
About the Series
This is part of Fun with Learning Technology, a series built around teaching real software engineering concepts the way they come up on the job and in interviews ā not just syntax, but the reasoning that gets you from "why is this slow" to a solution you can defend to another engineer.
Call To Action
If this cleared up something that's been fuzzy, drop a like on the video and subscribe on YouTube ā new lessons go up regularly. For deeper written breakdowns like this one, subscribe to the Substack newsletter. And if you've got a guess on what gather does when one task times out, or a question about where this breaks down, leave a comment ā always good to see how others are reasoning through it.
The solution
async def fetch_all(urls):
tasks = [fetch(url) for url in urls]
results = await asyncio.gather(*tasks)
return results
asyncio.run(fetch_all(urls))Ready to try it yourself? Solve Python problems with instant feedback in the practice sandbox.
Practice now ā


