Last modified: Aug 31, 2026
BeautifulSoup Async: Speed Up Web Scraping
Web scraping can be slow when you fetch many pages one by one. Standard requests blocks the program until a response arrives. This wastes time. BeautifulSoup async solves this problem. It lets you fetch multiple pages at once. This makes your scraper much faster.
This guide explains how to use BeautifulSoup with async Python. You will learn about asyncio and aiohttp. We will write clear examples. By the end, you will build a fast concurrent scraper. Let's dive in.
Why Use Async with BeautifulSoup?
BeautifulSoup itself is a parsing library. It is synchronous. It parses HTML after you give it the content. The bottleneck is network I/O. Fetching a page takes seconds. Parsing it takes milliseconds. Async helps with the waiting part.
When you use standard requests, your code waits for each response. Async lets you send many requests at once. While one response is in transit, your program works on another. This is called concurrency. It dramatically reduces total runtime.
Imagine scraping 100 pages. Synchronous code might take 100 seconds. Async code might finish in 10 seconds. That speed boost is huge. For large projects, this is essential. You can learn more about the basics in our What is BeautifulSoup? Web Scraping Guide.
Setting Up Your Environment
First, you need to install the required libraries. You will need beautifulsoup4 and aiohttp. You might also want lxml for faster parsing. Open your terminal and run this command.
pip install beautifulsoup4 aiohttp lxml
This installs all packages. aiohttp is an async HTTP client. It works perfectly with asyncio. Now, let's write our first async scraper.
Basic Async Scraper with aiohttp
We will start with a simple example. This code fetches a single page asynchronously. It uses asyncio to run the event loop. The fetch function gets the page content. Then, we parse it with BeautifulSoup.
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def fetch(session, url):
"""Fetch a URL and return the HTML content."""
async with session.get(url) as response:
return await response.text()
async def main():
url = "https://example.com"
async with aiohttp.ClientSession() as session:
html = await fetch(session, url)
soup = BeautifulSoup(html, 'lxml')
print(soup.title.string)
# Run the async function
asyncio.run(main())
In this code, asyncio.run() starts the event loop. The ClientSession manages connections. The fetch function is a coroutine. It awaits the response text. This is the core pattern for async scraping.
Scraping Multiple Pages Concurrently
Now, let's scale it up. We will scrape multiple URLs at the same time. We create a list of URLs. Then, we use asyncio.gather() to run multiple fetch tasks. This is where the speed comes from.
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def fetch_and_parse(session, url):
"""Fetch a page and extract the title."""
try:
async with session.get(url) as response:
html = await response.text()
soup = BeautifulSoup(html, 'lxml')
title = soup.title.string if soup.title else "No title"
return f"URL: {url} -> Title: {title}"
except Exception as e:
return f"Error fetching {url}: {e}"
async def main():
urls = [
"https://example.com",
"https://httpbin.org/html",
"https://www.python.org",
"https://github.com",
]
async with aiohttp.ClientSession() as session:
tasks = [fetch_and_parse(session, url) for url in urls]
results = await asyncio.gather(*tasks)
for result in results:
print(result)
asyncio.run(main())
Notice how we create a task for each URL. asyncio.gather() runs them all concurrently. The output shows that all pages are processed. The order might be different each time. That is normal for concurrent tasks.
URL: https://example.com -> Title: Example Domain
URL: https://httpbin.org/html -> Title: Httbin: HTTP Client Testing Service
URL: https://www.python.org -> Title: Welcome to Python.org
URL: https://github.com -> Title: GitHub: Let's build from here
This is much faster than doing it synchronously. You can add more URLs without slowing down much. The bottleneck becomes your network connection and the server's limits.
Adding a Rate Limiter
Scraping too fast can get you blocked. It is polite to add a delay between requests. This is called rate limiting. We can use asyncio.sleep() to pause. But we want to do it globally, not per task. A semaphore is a better solution.
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def fetch_with_semaphore(session, url, semaphore):
"""Fetch a URL with a concurrency limit."""
async with semaphore:
async with session.get(url) as response:
html = await response.text()
soup = BeautifulSoup(html, 'lxml')
return soup.title.string if soup.title else "No title"
async def main():
urls = [f"https://httpbin.org/html" for _ in range(10)]
semaphore = asyncio.Semaphore(3) # Limit to 3 concurrent requests
async with aiohttp.ClientSession() as session:
tasks = [fetch_with_semaphore(session, url, semaphore) for url in urls]
results = await asyncio.gather(*tasks)
print(f"Scraped {len(results)} pages with a limit of 3 concurrent requests.")
asyncio.run(main())
The Semaphore limits how many tasks can run at once. Here, we set it to 3. This prevents overwhelming the server. It is a good practice for ethical scraping. This technique is covered more in our BeautifulSoup Multithreading for Faster Web Scraping guide.
Handling Errors Gracefully
Network requests fail. Servers return 404 or 500 errors. Your code should handle these. In async, you can catch exceptions just like in sync code. The try and except blocks work the same way.
You can also check the response status. aiohttp allows you to check response.status. If it is not 200, you can skip or retry. This prevents your scraper from crashing.
async def safe_fetch(session, url):
"""Fetch a URL and handle errors."""
try:
async with session.get(url) as response:
if response.status == 200:
html = await response.text()
return html
else:
print(f"Got status {response.status} for {url}")
return None
except aiohttp.ClientError as e:
print(f"Network error for {url}: {e}")
return None
In this function, we check the status. We also catch aiohttp.ClientError. This includes timeouts and connection errors. Your main scraper can then check if the result is None before parsing.
Parsing with BeautifulSoup Async
Remember, BeautifulSoup parsing is synchronous. You cannot make soup.find_all() async. But that is fine. Parsing is fast. The async part is only for fetching. You can still use all BeautifulSoup features.
Here is an example of extracting links from multiple pages. We fetch the HTML asynchronously, then parse it normally. This combines the best of both worlds.
async def extract_links(session, url):
"""Fetch a page and extract all links."""
async with session.get(url) as response:
html = await response.text()
soup = BeautifulSoup(html, 'lxml')
links = [a.get('href') for a in soup.find_all('a', href=True)]
return url, links[:5] # Return first 5 links
# In main:
# results = await asyncio.gather(*(extract_links(session, u) for u in urls))
This function returns the URL and its links. You can process this data further. This is how you build a concurrent crawler. For more advanced parsing, check our Custom HTML Parser with BeautifulSoup guide.
Performance Comparison: Sync vs Async
Let's see the actual difference. We will write a simple benchmark. It will scrape 5 URLs with sync and async code. We will measure the time taken.
import time
import requests
import asyncio
import aiohttp
from bs4 import BeautifulSoup
# Synchronous version
def sync_scrape(urls):
for url in urls:
response = requests.get(url)
soup = BeautifulSoup(response.text, 'lxml')
soup.title
return "Done"
# Async version
async def async_scrape(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch_and_parse(session, url) for url in urls]
await asyncio.gather(*tasks)
return "Done"
urls = ["https://httpbin.org/html"] * 5
# Benchmark sync
start = time.time()
sync_scrape(urls)
sync_time = time.time() - start
# Benchmark async
start = time.time()
asyncio.run(async_scrape(urls))
async_time = time.time() - start
print(f"Sync time: {sync_time:.2f} seconds")
print(f"Async time: {async_time:.2f} seconds")
print(f"Speedup: {sync_time / async_time:.2f}x")
On a typical connection, async is several times faster. The exact speedup depends on network latency. The more pages you scrape, the bigger the difference. This is why async is crucial for large scraping tasks.
Sync time: 2.34 seconds
Async time: 0.85 seconds
Speedup: 2.75x
Best Practices for Async Scraping
Async scraping is powerful but needs care. Always respect the website's robots.txt. Set a proper User-Agent header. This identifies your bot. Use timeouts to avoid hanging requests. aiohttp allows you to set timeouts per request.
Also, be mindful of memory. If you fetch hundreds of pages at once, you might use a lot of RAM. Use a semaphore to limit concurrency. This keeps your system stable. It also prevents IP bans.
Finally, test your code with a small set of URLs first. This helps you debug issues. Once it works, scale up. This approach saves time and frustration.
Conclusion
BeautifulSoup async is a game-changer for web scraping. It combines the simplicity of BeautifulSoup with the speed of asyncio. You can scrape hundreds of pages in seconds. This guide showed you how to set up aiohttp and use asyncio.gather().
We covered rate limiting and error handling. These are key for production scrapers. We also compared performance. Async is clearly faster. Use these techniques in your next project.
Remember to scrape responsibly. Always check the website's terms of service. Use delays and limit concurrency. This keeps the web healthy for everyone. Now, go build your fast scraper.