Tutorial7 min read

Asynchronous Web Scraping in Python

Short answer

Asynchronous web scraping in Python sends many requests at once from a single thread: while one request waits for the server, asyncio starts or finishes others, so a scraper that spends most of its time waiting on the network gets through pages several times faster. You write the fetch as an async def function with aiohttp or httpx, cap how many requests run together with an asyncio.Semaphore, and collect the results with asyncio.gather or a TaskGroup. ProxyField residential proxies take aiohttp's proxy= or httpx's proxy= argument and allow unlimited concurrent threads, from $1.95/GB; start with the Easy Trial: 1 GB for $2.95.

residential IPs
412M+
from, traffic never expires
$1.95/GB
Easy Trial
1 GB for $2.95
On this page 11 sections
  1. Synchronous vs asynchronous scraping
  2. asyncio in five lines
  3. A scraper with aiohttp
  4. The same scraper with httpx
  5. Retries, rate limits and backoff
  6. Crawling with a queue and workers
  7. Parsing and saving without blocking the loop
  8. Async browsers: Playwright's async API
  9. Common asyncio errors in scrapers
  10. Proxies for async scrapers
  11. Asynchronous Web Scraping in Python FAQ

Synchronous vs asynchronous scraping

A synchronous scraper, such as a loop over requests.get(), sends one request, waits for the whole response, parses it, and only then sends the next. Almost all of that time is spent waiting. An asynchronous scraper starts the next request while the previous ones are still in flight.

FeatureSynchronous (Requests)Threads (ThreadPoolExecutor)Asynchronous (asyncio)
Requests in flight1One per threadHundreds per thread, as you allow
Code changesNoneSmallasync/await throughout the fetch path
Memory per requestLowA thread eachA small task object
LibrariesRequestsRequestsaiohttp, httpx, Playwright async API
Good forA few pages, scriptsSpeeding up existing codeThousands of pages, crawlers

How much you gain depends on how slow the responses are. On 2026-10-23, from a test server, 8 pages of a practice site took 1.7 to 3.0 s one after another with Requests, and 0.9 to 1.8 s four at a time with aiohttp or httpx (two runs each). That site answers in a fraction of a second; through a proxy or to a distant server, where each response takes a second or more, running requests in parallel saves far more. Threads give a similar gain for a modest number of requests and need fewer code changes; the comparison is in make web scraping faster.

asyncio in five lines

Python
import asyncio

async def fetch(n):
    await asyncio.sleep(1)          # stands in for a network request
    return n

async def main():
    return await asyncio.gather(*(fetch(n) for n in range(10)))

print(asyncio.run(main()))          # about 1 second, not 10
  • async def defines a coroutine; calling it returns an object and runs nothing yet.
  • await runs it and lets other tasks proceed while this one waits.
  • asyncio.gather runs many coroutines together and returns their results in order.
  • asyncio.run starts the event loop for the whole program.

Only libraries built for asyncio yield control while they wait. requests.get() inside an async def blocks the whole loop, so use an async HTTP client.

A scraper with aiohttp

Shell
pip install aiohttp beautifulsoup4 lxml
Python
import asyncio

import aiohttp
from bs4 import BeautifulSoup

BASE = "https://www.scrapethissite.com/pages/forms/?page_num={}"
CONCURRENCY = 4


def parse(html):
    soup = BeautifulSoup(html, "lxml")
    return [td.get_text(strip=True) for td in soup.select("tr.team td.name")]


async def fetch(session, sem, url):
    async with sem:                                   # at most CONCURRENCY requests at once
        async with session.get(url) as resp:
            resp.raise_for_status()
            return await resp.text()


async def main(pages):
    sem = asyncio.Semaphore(CONCURRENCY)
    timeout = aiohttp.ClientTimeout(total=30)
    headers = {"User-Agent": "Mozilla/5.0"}
    async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
        tasks = [fetch(session, sem, BASE.format(n)) for n in range(1, pages + 1)]
        htmls = await asyncio.gather(*tasks)
    return [name for html in htmls for name in parse(html)]


teams = asyncio.run(main(8))
print(len(teams), "teams")          # 200 teams (8 pages of 25)

Four details that matter:

  • One ClientSession for the whole run. It keeps a connection pool; creating a session per request is slow and leaves connections open.
  • The semaphore is your politeness and stability control. Without it, gather fires every request at once, which overloads the site, gets you rate-limited and runs into the client's connection limit. Start with 4 to 10 per site.
  • A total timeout stops one slow page from holding up the batch.
  • gather raises the first error by default, and the other requests keep running. Pass return_exceptions=True to collect errors alongside results, then retry the failed URLs.

aiohttp 3.14.5 (Apache-2.0 and MIT, Python 3.10+) was current on PyPI on 2026-10-23.

The same scraper with httpx

httpx offers a Requests-like API in both sync and async forms, which makes it the easier switch from Requests. This version uses a TaskGroup (Python 3.11+), which cancels the remaining tasks if one fails:

Python
import asyncio

import httpx
from bs4 import BeautifulSoup

BASE = "https://www.scrapethissite.com/pages/forms/?page_num={}"


async def fetch(client, sem, url):
    async with sem:
        r = await client.get(url)
        r.raise_for_status()
        return r.text


async def main(pages):
    sem = asyncio.Semaphore(4)
    limits = httpx.Limits(max_connections=4)
    async with httpx.AsyncClient(timeout=30, limits=limits, headers={"User-Agent": "Mozilla/5.0"}) as client:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(fetch(client, sem, BASE.format(n))) for n in range(1, pages + 1)]
    names = []
    for t in tasks:
        names += [td.get_text(strip=True) for td in BeautifulSoup(t.result(), "lxml").select("tr.team td.name")]
    return names


print(len(asyncio.run(main(8))), "teams")

httpx 0.28.1 was current on PyPI on 2026-10-23. It supports HTTP/2 with pip install "httpx[http2]" and AsyncClient(http2=True), which can carry many requests over one connection to the same host.

Featureaiohttphttpx
API styleIts own (session.get, await resp.text())Requests-like, sync and async
HTTP/2NoYes, optional
Speed at high concurrencyOften reported as the faster of the two; measure on your targetFast enough for most scrapers
Also a server frameworkYesNo
Proxy argumentsession.get(url, proxy=...)AsyncClient(proxy=...)

Retries, rate limits and backoff

Concurrency makes errors more likely, so every async scraper needs a retry rule:

Python
import random

async def fetch_with_retry(session, sem, url, tries=4):
    for attempt in range(tries):
        try:
            async with sem, session.get(url) as resp:
                if resp.status in (429, 500, 502, 503, 504):
                    raise aiohttp.ClientResponseError(resp.request_info, resp.history, status=resp.status)
                resp.raise_for_status()
                return await resp.text()
        except (aiohttp.ClientError, asyncio.TimeoutError):
            if attempt == tries - 1:
                raise
            await asyncio.sleep(2 ** attempt + random.random())   # 1, 2, 4 s plus jitter

On a 429, honor the Retry-After header when the server sends one, and lower the semaphore if 429s keep coming: the site is telling you that you are too fast. More on reading these responses in 429 Too Many Requests.

Crawling with a queue and workers

gather works when you know every URL in advance. A crawler discovers URLs as it goes, so use an asyncio.Queue with a fixed number of worker tasks:

Python
# sketch: define find_links() and parse() first; this block does not run as printed
async def worker(session, queue, seen, results):
    while True:
        url = await queue.get()
        try:
            async with session.get(url) as resp:
                html = await resp.text()
            results.append(parse(html))
            for link in find_links(html, url):          # your own link extractor
                if link not in seen:
                    seen.add(link)
                    queue.put_nowait(link)
        finally:
            queue.task_done()

async def crawl(start, n_workers=5):
    queue, seen, results = asyncio.Queue(), {start}, []
    queue.put_nowait(start)
    async with aiohttp.ClientSession() as session:
        workers = [asyncio.create_task(worker(session, queue, seen, results)) for _ in range(n_workers)]
        await queue.join()                              # wait until every queued URL is done
        for w in workers:
            w.cancel()
    return results

The number of workers is the concurrency limit. For a full crawler with robots.txt handling and depth limits, see how to crawl a website; for pagination patterns, web scraping pagination.

Parsing and saving without blocking the loop

Parsing with Beautiful Soup is ordinary synchronous Python. For small pages it takes milliseconds and does no harm, but heavy parsing of large pages stalls every request in flight. Move it off the loop:

Python
rows = await asyncio.to_thread(parse, html)     # runs parse() in a worker thread

For CPU-heavy work on many pages, a ProcessPoolExecutor with loop.run_in_executor uses several cores. Write results in batches at the end, or through aiofiles if you stream them to disk as you go.

Async browsers: Playwright's async API

The same model works for pages that need JavaScript. Playwright has an async API, so one process can drive several pages at once:

Python
from playwright.async_api import async_playwright

async def render(browser, sem, url):
    async with sem:
        page = await browser.new_page()
        try:
            await page.goto(url)
            return await page.content()
        finally:
            await page.close()

async def main(urls):
    sem = asyncio.Semaphore(3)                       # browser pages are heavy: keep this low
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        return await asyncio.gather(*(render(browser, sem, u) for u in urls))

Each browser page costs far more memory and traffic than an HTTP request, so keep the limit low. When to render at all is covered in Python dynamic web scraping. Scrapy is asynchronous too and handles concurrency, retries and queues for you; see the Scrapy tutorial.

Common asyncio errors in scrapers

ErrorCauseFix
RuntimeError: asyncio.run() cannot be called from a running event loopYou are in Jupyter or another running loopUse await main() directly in the notebook cell
RuntimeWarning: coroutine '...' was never awaitedYou called an async function without awaitAdd await, or pass it to gather or create_task
Unclosed client sessionA session was not closedUse async with aiohttp.ClientSession() as session
Many TimeoutErrors at high concurrencyToo many requests at once, or the site is slowing you downLower the semaphore; add retries with backoff
The scraper is no faster than beforeA blocking call (requests, time.sleep) inside async codeUse the async client and await asyncio.sleep()

Proxies for async scrapers

Concurrency multiplies requests per IP, which is what per-IP rate limits count. Slow down first; if a per-IP limit still stops a lawful scrape, route the requests through a proxy. Both clients take the proxy URL directly:

Python
PROXY = "http://<login>-country-us-city-new_york:<password>@gate.proxyfield.io:7000"

# aiohttp: per request
async with session.get(url, proxy=PROXY) as resp: ...

# httpx: per client
async with httpx.AsyncClient(proxy=PROXY) as client: ...

gate.proxyfield.io:7000 is the ProxyField gateway; <login>-country-us-city-new_york asks for an exit IP in New York. Region, session and session time go in the same username: -region-<region>, -session-<id> and -ttl-<seconds> (30 to 129,600 seconds, 20 minutes by default), as in <login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional. The gateway can give each request a new IP, or keep one IP for a sticky session from 30 s to 36 h, and it allows unlimited concurrent threads, so your semaphore, not the proxy, sets the pace. More proxy setups for httpx and aiohttp, including SOCKS5, are on the Python proxy setup page.

Running async scrapers at volume? ProxyField residential proxies: 412M+ residential IPs in 195+ countries, a new IP on every request or sticky sessions up to 36 h, unlimited concurrent threads, from $1.95/GB with no subscription; start with the Easy Trial (1 GB). Traffic never expires, and all purchases are final.

Asynchronous Web Scraping in Python FAQ

Why use asyncio for web scraping?

ProxyField proxies allow unlimited concurrent threads, and asyncio is how a Python scraper uses that concurrency: a scraper spends most of its time waiting for servers, and asyncio keeps many requests in flight from one thread while it waits. That makes large scrapes several times faster than a one-by-one loop, with less memory than one thread per request; ProxyField residential proxies for it start at $1.95/GB. It pays off from a few dozen pages upward; for a handful, plain Requests is simpler.

What is asynchronous web scraping?

It is scraping in which requests don't wait for each other: the program sends a request, moves on to others while the server responds, and handles each response when it arrives. In Python that means asyncio with an async HTTP client such as aiohttp or httpx.

What is the main difference between synchronous and asynchronous scraping?

A synchronous scraper finishes each request before it starts the next, so its speed is the sum of every response time. An asynchronous scraper overlaps them, so its speed depends on how many requests you allow at once and how fast the site answers.

Do I need special tools or libraries for asynchronous scraping?

You need an HTTP client built for asyncio, such as aiohttp or httpx; asyncio itself is part of Python's standard library. Parsers like Beautiful Soup and lxml work unchanged, and Playwright has an async API for pages that need a browser.

Why use aiohttp for asynchronous web scraping?

It is a mature asyncio HTTP client with connection pooling, timeouts, cookie handling and per-request proxies, and it is often reported as one of the faster options at very high concurrency; measure on your own target. httpx is the alternative if you want a Requests-like API or HTTP/2.

Is aiohttp better than Requests library?

For many concurrent requests, yes, because it doesn't block while it waits. For a short script that fetches a few pages one at a time, Requests is simpler and just as fast, and it has no event loop to manage.

Is asyncio better than threads?

For thousands of network requests, usually: tasks use less memory than threads and you control concurrency in one place. Threads are the easier choice when you want to speed up existing Requests code or use a library that has no async version.

Why is asyncio not thread-safe?

An event loop and its objects are meant to be used from the thread that runs the loop; they don't lock against calls from other threads. To hand work to the loop from another thread, use asyncio.run_coroutine_threadsafe or loop.call_soon_threadsafe, and to run blocking code from async code, use asyncio.to_thread.

Is aiohttp a framework?

It is both an HTTP client and a web server framework for asyncio. Scrapers use only the client part, aiohttp.ClientSession.

Can proxies help with asynchronous scraping?

Yes, when per-IP rate limits cap how fast a lawful scrape can go: with a rotating gateway each concurrent request can leave from a different IP. Both aiohttp and httpx take a proxy URL, and the semaphore in your code still decides how many requests run at once.

Easy Trial: 1 GB for $2.95.

Try for the price of 1 GB, no subscription, traffic never expires.