Why Is My Web Scraper Slow? How to Make It Faster
A scraper that needs hours for a few thousand pages is almost always fetching one page at a time: 3,000 pages at 2.4 s each is 7,200 s, two hours, and the same job with 20 requests in flight takes about 6 minutes (7,200 / 20 = 360 s), as far as the site's limits allow. Make web scraping faster in this order: send requests concurrently (async HTTP, a thread pool, or Scrapy's concurrency settings), call the JSON API the page uses instead of rendering it, reuse connections, block images and fonts when you must render, parse less and batch your writes, and fetch only the pages you need. ProxyField has unlimited concurrent threads and an average response of about 400 ms, according to our service data; 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 12 sections
- The main ways to make a scraper faster, by typical gain
- Why is my web scraper slow? Find the bottleneck first
- 1. Concurrency: the biggest win
- 2. Find the API instead of rendering the page
- 3. Reuse connections
- 4. When you must render, render less
- 5. Parse faster and save smarter
- 6. Fetch less
- How many concurrent requests can you run through proxies?
- Concurrency in proxies and scraping APIs
- Related guides
- Why Is My Web Scraper Slow? How to Make It Faster FAQ
The main ways to make a scraper faster, by typical gain
| # | Change | What it removes | Typical gain (logic, from the arithmetic below) |
|---|---|---|---|
| 1 | Concurrency: 10-50 requests in flight | Waiting on one response at a time | Up to N times faster at N requests in flight, until the site limits you |
| 2 | Call the page's JSON API, not the browser | Rendering, scripts, images | One small request instead of dozens per page |
| 3 | Shorter fixed delays, a per-host limit instead | Sleeps longer than the site needs | The whole sleep time, on hosts that allow it |
| 4 | Reuse connections (one Session or Client) | A new TCP and TLS handshake per request | One or more round trips per request |
| 5 | Block images, fonts and media when rendering | Downloads the parser never reads | Most of a rendered page's bytes |
| 6 | Faster parser, batched writes | CPU and disk waits | Matters once the network is no longer the bottleneck |
| 7 | Fetch less: sitemaps, 304 Not Modified, bigger pages | Requests you did not need | Every skipped page |
Start with the numbers for your own job: total time is about pages × seconds per page ÷ requests in flight.
| Pages | Seconds per page | In flight | Time |
|---|---|---|---|
| 3,000 | 2.4 | 1 | 2 h |
| 3,000 | 2.4 | 5 | 24 min |
| 3,000 | 2.4 | 20 | 6 min |
| 10,000 | 1.0 | 20 | about 8 min |
Why is my web scraper slow? Find the bottleneck first
Measure before you change anything. Time each stage for 20 pages: waiting for the response, downloading, parsing, saving. One of them is almost always most of the total.
| Symptom | Likely cause | Fix |
|---|---|---|
| Each page takes about the same 0.5-2 s, one after another | Sequential requests: the scraper waits on the network | Concurrency (async or threads) |
| Long pauses between pages | A fixed sleep() or DOWNLOAD_DELAY longer than the site needs | A per-host limit, while other hosts run in parallel |
| Every page takes several seconds and lots of CPU | A headless browser renders pages that plain HTTP could fetch | Use the page's API or HTML; render only where needed |
| Rendering is needed but still slow | The browser downloads images, fonts, video and trackers | Block those resource types; wait for your element, not networkidle |
| Fast at first, then many 429 or 503 responses and retries | The site rate-limits you | Lower per-host concurrency, honor Retry-After, spread requests over more IPs |
| Network is fast but parsing lags | A slow parser on large pages | lxml or selectolax; parse only the part you need |
| Saving takes as long as fetching | One database write per row | Batch inserts; write in a separate thread or task |
| Requests are slow through the proxy | A long route: client to exit IP to the site's servers | Choose an exit country near the site's servers |
import time, requests
s = requests.Session()
t0 = time.perf_counter(); r = s.get("https://example.com/page/1", timeout=30); t1 = time.perf_counter()
soup_t0 = time.perf_counter(); n = r.text.count("<tr"); soup_t1 = time.perf_counter()
print(f"network {t1 - t0:.2f}s, parse {soup_t1 - soup_t0:.3f}s, {len(r.content) / 1024:.0f} KB")If network time is most of the total, concurrency is your fix. If parse or save time is, more requests in flight will not help.
1. Concurrency: the biggest win
Concurrency means keeping several requests in flight at once, so the scraper works while it waits. Throughput is about concurrency divided by latency: with 1 request in flight and 0.5 s per response you get 2 pages per second; with 20 in flight, about 40, until the site, your bandwidth or your CPU becomes the limit.
Concurrency is not the same as parallelism. Concurrency interleaves many waiting tasks on one core (async I/O, threads that mostly wait); parallelism runs work on several cores at the same time (processes). Scrapers are I/O-bound, so concurrency gives most of the speed; parallel processes help only when parsing is the bottleneck.
Async HTTP in Python (httpx)
# pip install httpx beautifulsoup4
import asyncio, httpx
from bs4 import BeautifulSoup
PROXY = "http://<login>-country-us-city-new_york:<password>@gate.proxyfield.io:7000"
URLS = [f"https://www.scrapethissite.com/pages/forms/?page_num={n}" for n in range(1, 11)]
async def fetch(client, sem, url):
async with sem: # at most N requests in flight
for attempt in range(3):
try:
r = await client.get(url)
if r.status_code in (429, 503):
await asyncio.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
continue
r.raise_for_status()
return len(BeautifulSoup(r.text, "html.parser").select("tr.team"))
except httpx.TransportError:
await asyncio.sleep(2 ** attempt)
return None
async def main():
sem = asyncio.Semaphore(5) # tune per site
async with httpx.AsyncClient(proxy=PROXY, timeout=30,
headers={"User-Agent": "Mozilla/5.0"}) as client:
results = await asyncio.gather(*(fetch(client, sem, u) for u in URLS))
print(results)
asyncio.run(main())The Semaphore is the throttle: raise it while responses stay fast and clean, lower it as soon as 429s or timeouts rise. The proxy= argument needs httpx 0.26 or later. The Python async setup, aiohttp included, is in Python proxy setup.
Threads for code you don't want to rewrite
If your scraper uses Requests, a thread pool gives you concurrency without async:
from concurrent.futures import ThreadPoolExecutor
import requests, threading
local = threading.local()
def session():
if not hasattr(local, "s"):
local.s = requests.Session() # one Session per thread
return local.s
def fetch(url):
return session().get(url, timeout=30).status_code
with ThreadPoolExecutor(max_workers=10) as pool:
print(list(pool.map(fetch, URLS)))Scrapy: the settings that decide its speed
Scrapy is concurrent by design, but a new project is polite by default: the startproject template sets DOWNLOAD_DELAY = 1, one request per second per domain, which alone makes 3,000 pages on one site take 50 minutes. These are the settings to change in settings.py (Scrapy: optimizing performance, settings reference):
# settings.py: a faster starting point for a site that tolerates it
CONCURRENT_REQUESTS = 32 # total requests in flight
CONCURRENT_REQUESTS_PER_DOMAIN = 16 # per site; the real limit on a single-site crawl
DOWNLOAD_DELAY = 0 # drop the template's 1 s; let AutoThrottle pace you
AUTOTHROTTLE_ENABLED = True # adjusts the delay to the site's observed latency
AUTOTHROTTLE_TARGET_CONCURRENCY = 8 # average requests in flight per site
COOKIES_ENABLED = False # unless the site needs a session
RETRY_TIMES = 1 # fewer retries on hopeless URLs
DOWNLOAD_TIMEOUT = 30 # do not wait minutes on a dead request
LOG_LEVEL = "INFO"For a broad crawl over many sites, set CONCURRENT_REQUESTS close to CONCURRENT_REQUESTS_PER_DOMAIN × the number of sites: 8 per site over 10 sites is 80 in flight.
Read the stats line Scrapy logs every minute to see whether a change helped:
INFO: Crawled 1200 pages (at 60 pages/min), scraped 1150 items (at 58 items/min)- Pages per minute stays flat after you raise concurrency: the limit is elsewhere. Check the delay, the site's 429 or 503 responses, or your callbacks.
- The
retry/countstat climbs and 429s appear: you are above what the site allows; lowerCONCURRENT_REQUESTS_PER_DOMAINor spread requests over more IPs. - Requests in flight stay at your
CONCURRENT_REQUESTSvalue (len(engine.downloader.active)in Scrapy's telnet consoleest()): the downloader is the limit, so more concurrency or more IPs can help. - The scheduler queue (
len(engine.scheduler.mqs)) keeps growing: the spider finds links faster than it downloads them, which is normal for a crawl; it is not a slowdown by itself.
Proxy setup for Scrapy is in Scrapy proxy setup; the spider itself is in the Scrapy tutorial. Crawlee is concurrent by design in the same way (maxConcurrency).
Robots.txt. A site's robots.txt may set Crawl-delay (seconds between requests). Scrapy applies a site's robots.txt rules when ROBOTSTXT_OBEY = True, the template's setting; to respect a Crawl-delay, set DOWNLOAD_DELAY to that value for the site, and keep your concurrency for the other sites. More on pacing a crawl: what is a web crawler.
2. Find the API instead of rendering the page
The biggest single gain after concurrency is often skipping the browser altogether. Many pages load their data from a JSON request after the HTML arrives; call that request directly and you get the data without scripts, images or layout.
- Open the page in Chrome, press F12 and open the Network panel (Chrome DevTools: Network).
- Select the Fetch/XHR filter and reload the page.
- Scroll, page or click to load more data; watch which request returns it.
- Click the request and open Response: you are looking for JSON with the fields you scrape, such as
{"items": [{"title": "...", "price": 19.99}], "next_page": 2}. - Right-click the request, Copy as cURL, and replay it from your code with the same query parameters and the headers it needs.
- Change the page or
limitparameter to step through results, often at 50-100 items per call instead of 20 per HTML page.
The full method, including JSON embedded in the HTML (__NEXT_DATA__ and similar), is in how to scrape hidden APIs.
3. Reuse connections
Opening a new TCP and TLS connection for every request can cost more than the request itself. A requests.Session or one httpx.Client keeps connections alive between requests to the same host; httpx.Client(http2=True) (with pip install httpx[http2]) multiplexes many requests over one connection where the server supports HTTP/2. Through a rotating proxy, each new exit IP needs its own connection to the site, so reuse helps most with sticky sessions and with the connection to the gateway itself.
4. When you must render, render less
A headless browser is the slowest way to get a page: it downloads and runs every script, then lays out the page. Before you render, check whether the data is in the HTML or in a JSON request (section 2, and how to scrape dynamic web pages). When you must render:
- block images, media and fonts with request interception (in Playwright,
page.route("**/*", ...)and abort byrequest.resource_type); - wait for the element you need (
wait_for_selector) instead ofnetworkidleor a fixed sleep; - reuse one browser and open several contexts or pages in it, rather than launching a browser per URL;
- close pages you are done with, and restart the browser if its memory keeps growing over a long run.
Selenium follows the same rules: run it headless, set page_load_strategy = "eager" so it stops waiting for every image, reuse one driver and move pages that need no browser to plain HTTP. The full list of Selenium speed-ups is in Selenium web scraping, and the proxy setup in Selenium proxy setup.
5. Parse faster and save smarter
- Use the lxml parser (
BeautifulSoup(html, "lxml")) or lxml directly; selectolax is faster still for CSS-selector work. - Parse only the fragment you need: find the container first, then select inside it.
- Batch database writes (
executemany, or a bulk insert every few hundred rows), and move writing to its own thread or task so fetching never waits on disk.
6. Fetch less
The fastest request is the one you skip:
- start from sitemaps or listing APIs instead of crawling every link;
- skip URLs you already have unless they changed: send
If-Modified-SinceorIf-None-Matchand accept304 Not Modified(MDN: conditional requests); - request bigger pages where the site allows it (
per_page=100instead of 20); - deduplicate URLs before they reach the queue;
- in development, cache responses (Scrapy's
HTTPCACHE_ENABLED = True) so reruns do not refetch.
How many concurrent requests can you run through proxies?
ProxyField does not limit concurrent threads, so the real limits are the target site, your machine and your network. A practical way to find the number:
- Start at 2-5 concurrent requests per site.
- Double it while the error rate stays low and response times stay flat.
- Stop and step back at the first rise in 429, 503, 403 or timeouts.
- Spread across sites: 50 requests in flight over 10 sites is gentler on each than 50 on one.
Higher concurrency against one site usually needs more IPs as well, because sites count requests per IP. With per-request rotation, each request leaves from a new IP out of 412M+ residential IPs in 195+ countries; sticky sessions (30 seconds to 36 hours) keep one IP when a flow needs it, and reuse its connection.
| Setting | Username part | Example |
|---|---|---|
| Gateway | host and port | gate.proxyfield.io:7000 (HTTP, HTTPS and SOCKS5 on the same port) |
| Country | -country-<cc> | -country-us |
| Region and city | -region-<region>-city-<city> | -region-new_ |
| Sticky session | -session-<id> | -session-he9f2qga (20 minutes by default) |
| Session time | -ttl-<seconds> | -ttl-600 (30 to 129,600 seconds) |
| Rotation | leave out -session | a new IP on every request |
A full line: gate.proxyfield.io:7000:<login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600:<password>; every part after the login is optional. Typical response times, according to our service data, are under 1 s for residential and under 1.5 s for mobile.
Concurrency in proxies and scraping APIs
Proxy providers and scraping APIs often sell or limit "concurrency": the number of requests or connections you may have open at the same time. It is not a requests-per-second limit: 10 concurrent requests that each take 2 s give you 5 requests per second, and the same 10 at 0.5 s give 20. When you compare offers, check both the concurrency limit and what is billed (GB of traffic, requests or successful results). ProxyField residential proxies are billed per GB with unlimited concurrent threads: $2.95/GB for 1 GB down to $1.95/GB from 1,000 GB. The ProxyField Web Scraping API is in development and will bill per 1,000 successful results.
Why Is My Web Scraper Slow? How to Make It Faster FAQ
Why is my web scraper slow?
ProxyField's gateway puts no cap on concurrent threads and has an average response of about 400 ms, according to our service data; a web scraper is usually slow for one of 3 reasons of its own: it fetches one page at a time and waits on each response, it sleeps longer than it needs to, or it renders pages in a browser that plain HTTP could fetch. Concurrent requests fix the first, per-host limits instead of fixed sleeps the second, and plain HTTP or the page's JSON API the third.
My scraper takes hours to get through a few thousand pages. How do I make it faster?
Hours for a few thousand pages means one request at a time: 3,000 pages at 2.4 s each is 2 hours, and 20 requests in flight bring it to about 6 minutes if the site allows it. Add concurrency first (async HTTP, a thread pool, or Scrapy's CONCURRENT_REQUESTS_PER_DOMAIN with DOWNLOAD_DELAY = 0), then call the page's JSON API instead of rendering it, and watch for 429 or 503 responses as you raise the number.
How do I speed up web scraping?
Measure each stage first, then send requests concurrently, reuse connections, call the page's API instead of rendering it, block images and fonts when you must render, use a fast parser and batch your writes. Fetching fewer pages, through sitemaps and conditional requests, often saves the most time of all.
Which Scrapy settings make a crawl faster?
CONCURRENT_REQUESTS, CONCURRENT_REQUESTS_PER_DOMAIN and DOWNLOAD_DELAY decide most of it; the startproject template's DOWNLOAD_DELAY = 1 limits a site to one request per second. Turn on AUTOTHROTTLE_ENABLED, set COOKIES_ENABLED = False unless you need cookies, and lower RETRY_TIMES and DOWNLOAD_TIMEOUT so dead URLs do not hold slots.
How do I tell whether the site, the network or my code is the bottleneck?
Time the network wait, the parse and the save for 20 pages: the largest share is the bottleneck. If raising concurrency does not raise pages per minute and 429 or 503 responses appear, the site is limiting you; if requests in flight stay at your concurrency cap, the network side is, and more concurrency or more IPs help.
What is concurrency in web scraping?
Keeping several requests in flight at once, so the scraper keeps working while it waits for responses. Throughput is roughly the number of requests in flight divided by the response time, until the site's limits or your hardware cap it.
What is concurrency in proxies and scraping APIs?
The number of requests or connections you may have open at the same time through the service. Some providers sell plans by concurrency; ProxyField residential proxies have unlimited concurrent threads and bill per GB, from $2.95/GB for 1 GB.
How many concurrent requests can I run through proxies?
As many as the proxy allows and the target site tolerates. Start at 2 to 5 per site, double while errors and response times stay flat, and step back at the first rise in 429, 403 or timeouts; spread high totals across many sites and IPs.
Is proxy concurrency the same as requests per second?
No. Concurrency counts requests open at the same moment; requests per second also depends on how long each takes. Ten concurrent requests at 2 seconds each give about 5 per second, and at 0.5 seconds about 20.
What is the difference between concurrency and parallel requests?
Concurrency interleaves many tasks that mostly wait, such as network requests, on one core with async I/O or threads. Parallelism runs work on several cores at once with processes; scrapers gain most from concurrency, and from parallelism only when parsing is the bottleneck.
Show 3 more questionsShow fewer questions
Does higher concurrency require more proxy IPs?
Against one site, usually yes, because sites limit requests per IP; per-request rotation spreads the load over many IPs. Across many sites, one IP per site at a modest pace can be enough.
Which is faster to scrape, static or dynamic content?
Static content, by a wide margin: one HTTP request returns the data, while a rendered page downloads and runs scripts and assets first. Dynamic content gets nearly as fast when you call the JSON API behind it instead of rendering the page.
Do sticky sessions make proxies faster?
They can for multi-page flows: keeping one IP lets you reuse the connection to the site and avoids new handshakes on every request. For independent pages, per-request rotation spreads load better and is usually the faster choice overall.
Related pages
Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.