How to Build a Web Crawler
To build a web crawler, start from seed URLs, keep a URL frontier (a queue of URLs to visit plus a set of URLs already seen), fetch each URL politely after checking robots.txt, extract and normalize the links, keep the ones inside your scope, and store each page or its fields before taking the next URL from the queue. A working single-machine crawler is about 70 lines of Python, below, and the same parts scale out with a shared queue, a database and more fetchers. ProxyField residential proxies give a crawler a new IP on every request 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
- The parts of a web crawler
- What a crawl frontier is
- Normalize and deduplicate URLs
- Be polite: robots.txt and pacing
- Python code: a polite single-machine crawler
- The web crawler database: what to store
- Crawler traps
- Scaling the crawler
- Proxies for a crawler
- Build your own or use a framework?
- How to Build a Web Crawler FAQ
The parts of a web crawler
Every crawler, from a weekend script to a search engine, has the same parts. What changes with scale is where each part lives.
| Part | Job | Single machine | At scale |
|---|---|---|---|
| Seeds | The URLs the crawl starts from | A list in the code | Sitemaps, a seed table |
| URL frontier | What to fetch next, in what order | A deque | Redis, Kafka or a database queue, sharded by host |
| Seen set | Never fetch the same URL twice | A Python set | A database index or a Bloom filter |
| robots.txt cache | What each host allows | A dictionary | A shared cache with a refresh time |
| Politeness scheduler | Delay per host, concurrency per host | A last_hit timestamp per host | Per-host queues with their own timers |
| Fetcher | Downloads pages, handles errors and retries | requests | Many async workers, with proxies |
| Parser and link extractor | Pulls fields and links out of each page | Beautiful Soup | The same, in workers |
| URL normalizer and scope filter | Turns links into one canonical form; drops what is out of scope | A function | The same function, shared |
| Storage | Pages, fields and crawl state | SQLite | Postgres, object storage, a search index |
| Monitoring | Pages per minute, errors by status | Print statements | Metrics and alerts |
What a crawl frontier is
The frontier is the crawler's to-do list: every discovered URL that has not been fetched yet, plus the rules for which one comes next. Its order shapes the crawl:
- FIFO (first in, first out) visits pages in breadth-first order: all pages one click from the seeds, then two clicks, and so on. Good for site-wide crawls with a depth limit.
- Priority orders URLs by a score (fewer clicks from the home page, a recent
lastmodin the sitemap, a URL pattern you care about). Good when you can't fetch everything. - Per-host queues keep one queue per host and take from a host only when its politeness delay has passed. That is how large crawlers stay fast overall while slow on each site; the design goes back to the Mercator crawler described by Heydon and Najork in 1999.
The seen set sits next to the frontier: a URL is added to the frontier only if it was never seen before. Without it, a crawler loops forever between pages that link to each other.
Normalize and deduplicate URLs
The same page hides behind many URLs: HTTP://Example.com/a#top, http://example.com/a, http://example.com/a?utm_source=x. Normalize every link before you check the seen set:
- Make it absolute with
urljoin(page_url, href). - Drop the
#fragment; it never changes what the server sends. - Lowercase the scheme and host, and drop default ports.
- Remove tracking parameters (
utm_*,gclid, session IDs) and, if order never matters on the site, sort the remaining query parameters. - If the page declares
<link rel="canonical">, store that as the page's URL.
For millions of URLs, a Bloom filter answers "seen before?" in a few bits per URL, with a small chance of a false "yes" and never a false "no". Deduplicate content too: hash the page body, and treat two URLs with the same hash as one page.
Be polite: robots.txt and pacing
robots.txt is the site's instruction to crawlers, standardized as RFC 9309 in 2022. Read it once per host, cache it, and check every URL against it before you fetch. Two practical notes:
- Fetch robots.txt with your own client and user agent. Python's
RobotFileParser.read()uses its own default user agent, and if a server answers that with 401 or 403, the parser treats the whole site as disallowed. The code below fetches the file with the crawler's session and passes the lines toparse(). Crawl-delayis not part of RFC 9309, but some sites set it; honoring it is a good default.
Pacing matters more than robots.txt for staying welcome: keep at least one second between requests to the same host for a small crawl, send a user agent that names your crawler and a contact URL, and back off when a host answers 429 or 503 or gets slower.
Python code: a polite single-machine crawler
This crawler starts from one seed, stays inside the allowed hosts, checks robots.txt, waits between requests to the same host, stores each page's status and title in SQLite, and adds new links to the frontier. It needs pip install requests beautifulsoup4. It was tested on the practice sandbox scrapethissite.com.
import sqlite3, time, urllib.robotparser
from collections import deque
from urllib.parse import urljoin, urldefrag, urlparse
import requests
from bs4 import BeautifulSoup
SEEDS = ["https://www.scrapethissite.com/pages/"]
ALLOWED = {"www.scrapethissite.com"}
MAX_PAGES, DELAY = 50, 1.0
UA = "my-crawler/1.0 (+https://example.com/contact)"
PROXY = None # e.g. "http://<login>:<password>@gate.proxyfield.io:7000"
def normalize(url, base):
url = urldefrag(urljoin(base, url))[0] # absolute, no #fragment
p = urlparse(url)
if p.scheme not in ("http", "https") or p.hostname not in ALLOWED:
return None
return p._replace(netloc=p.hostname, path=p.path or "/").geturl()
s = requests.Session()
s.headers["User-Agent"] = UA
if PROXY:
s.proxies.update({"http": PROXY, "https": PROXY})
robots = {}
def allowed(url):
host = urlparse(url).scheme + "://" + urlparse(url).netloc
if host not in robots:
rp = urllib.robotparser.RobotFileParser()
r = s.get(host + "/robots.txt", timeout=30) # with our own User-Agent and proxy
rp.parse(r.text.splitlines() if r.status_code == 200 else [])
robots[host] = rp
return robots[host].can_fetch(UA, url)
db = sqlite3.connect("crawl.db")
db.execute("CREATE TABLE IF NOT EXISTS pages (url TEXT PRIMARY KEY, status INT, title TEXT, fetched_at REAL)")
frontier = deque(SEEDS)
seen = set(SEEDS)
last_hit = {}
fetched = 0
while frontier and fetched < MAX_PAGES:
url = frontier.popleft()
if not allowed(url):
continue
host = urlparse(url).hostname
wait = DELAY - (time.time() - last_hit.get(host, 0))
if wait > 0:
time.sleep(wait) # politeness per host
try:
r = s.get(url, timeout=30)
except requests.RequestException as e:
print("error", url, e); continue
last_hit[host] = time.time()
fetched += 1
soup = BeautifulSoup(r.text, "html.parser") if "html" in r.headers.get("Content-Type", "") else None
title = soup.title.get_text(strip=True) if soup and soup.title else None
db.execute("INSERT OR REPLACE INTO pages VALUES (?, ?, ?, ?)", (url, r.status_code, title, time.time()))
db.commit()
if not soup or r.status_code != 200:
continue
for a in soup.select("a[href]"):
link = normalize(a["href"], url)
if link and link not in seen:
seen.add(link)
frontier.append(link)
print(fetched, "pages fetched,", len(frontier), "URLs left in the frontier")To use it on your own site, change SEEDS, ALLOWED and UA, then add your field extraction where the title is read. The Python-specific deep dive, with async fetching, is in the Python web crawler guide; a framework that does all of this for you is Scrapy.
The web crawler database: what to store
Store crawl state and content separately, so you can rerun parsing without refetching.
| Table | Columns | Why |
|---|---|---|
urls (the frontier and seen set) | url, host, depth, priority, discovered_at, status (queued, fetched, failed) | Resume a crawl after a crash; pick the next URL per host |
fetches | url, status_code, fetched_at, content_hash, bytes, error | Find blocks (403, 429) and slow hosts; schedule recrawls |
pages | content_hash, raw HTML (or a pointer to object storage) | Reparse old pages when your parser changes |
links | from_url, to_url | Site structure, link counts, priority scoring |
items | url, fields you extract, extracted_at | The data you actually wanted |
SQLite is enough for a few hundred thousand URLs on one machine. Past that, Postgres handles the frontier with an index on (status, host, priority), and raw HTML goes to object storage, compressed.
Crawler traps
Some sites generate endless URLs: calendars with a "next month" link forever, faceted filters that combine endlessly, session IDs in links, and relative links that build ever-longer paths. Guard against them with a maximum depth, a maximum number of URLs per host, a maximum URL length, and rules that skip parameter patterns you don't need (?sort=, ?filter=, ?sessionid=).
Scaling the crawler
When one machine is not enough, the parts above split into services:
- A shared frontier (Redis, Kafka or Postgres) that all workers read, partitioned by host so one worker owns each host's politeness.
- Async fetchers: one process with
asyncioandhttpxcan keep hundreds of requests in flight across many hosts while staying slow on each. Patterns: how to make web scraping faster. - Rendering only where needed: send pages that need JavaScript to a separate pool of headless browsers (how to scrape dynamic web pages).
- Recrawl scheduling: revisit pages by how often they change, using the
lastmodin sitemaps and the content hash from the last fetch. - Near-duplicate detection such as SimHash, for templated pages that differ only in a date or an ad.
Proxies for a crawler
A crawler that respects per-host delays still sends many requests from one IP across the day, and some sites limit per IP regardless of pace, or show different content by country. Proxies help with three things:
- Spreading load across IPs: with per-request rotation, each fetch leaves from a new residential IP.
- Keeping a session where needed: a sticky session (30 seconds to 36 hours) for sites that tie pages to one visitor.
- Seeing local versions: country, region and city targeting for sites that change content by location.
Set the proxy once on the crawler's session (PROXY in the code). The ProxyField gateway is gate.proxyfield.io:7000; <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. Unlimited concurrent threads means the limit on parallel fetches is the one you set per host. For sizing traffic and choosing the IP type, see proxies for web scraping.
Build your own or use a framework?
Build your own when you need to understand every part, crawl a few sites with custom rules, or learn. Use a framework when you need retries, concurrency, exports and middleware on day one: Scrapy (Python) and Crawlee (Node.js and Python) are the common open-source choices, and Apache Nutch and StormCrawler are built for very large crawls. Comparisons: web crawler tools compared. For the difference between crawling and scraping, and how to plan a whole-site crawl, see how to crawl a website.
Crawling at volume? ProxyField rotating residential proxies: 412M+ residential IPs in 195+ countries, a new IP per request, pay per GB from $1.95/GB with no subscription. Start with the Easy Trial (1 GB); traffic never expires, and all purchases are final.
How to Build a Web Crawler FAQ
How do I build a web crawler?
ProxyField residential proxies, from $1.95/GB, cover the IP side when you build a web crawler; the crawler itself has 6 parts: seed URLs, a URL frontier with a seen set, a robots.txt check, a polite fetcher with a delay per host, a link extractor that normalizes and filters links to your scope, and storage for pages and crawl state. In Python that is about 70 lines with Requests, Beautiful Soup and SQLite, and at volume each request can leave from a new residential IP.
How do I make a web crawler from scratch?
Write a loop that takes a URL from a queue, fetches it, extracts the links, adds unseen in-scope links to the queue and stores the page. Then add the parts that keep it safe to run: robots.txt, a delay per host, a page limit, retries and a database, so a crash does not lose the crawl.
What is a crawl frontier?
The set of discovered URLs a crawler has not fetched yet, plus the rules that pick the next one. A FIFO queue gives a breadth-first crawl; a priority queue fetches the most important URLs first; per-host queues keep the crawler polite on each site.
What is a seed URL?
A URL the crawl starts from, such as a home page, a category page or a sitemap. Every other URL is discovered from the seeds' links, so good seeds decide how much of a site the crawler can reach.
How do large crawlers deduplicate URLs?
They normalize each URL first (absolute, no fragment, lowercase host, no tracking parameters) and check it against a seen set kept in a database index or a Bloom filter. They also hash page content, so different URLs that return the same page are stored once.
What database should a web crawler use?
SQLite is enough for a single-machine crawl of a few hundred thousand URLs. Larger crawls keep the frontier and fetch log in Postgres, or a queue such as Redis or Kafka, and store raw HTML compressed in object storage.
How does a web crawler pick the next URL?
From its frontier, in the order its policy sets: first in, first out for breadth-first crawling, or by priority, such as depth, freshness or a URL pattern. A host's URL is taken only when that host's politeness delay has passed.
How often should crawlers rotate IPs?
For most crawls, on every request: each fetch leaves from a new IP, which spreads load and per-IP limits evenly. Keep one IP for a sticky session only when the site ties several pages to one visitor, such as a cart or a paginated search.
How do I keep a crawler out of infinite URL traps?
Set a maximum depth, a maximum number of URLs per host and a maximum URL length, and skip parameter patterns you don't need, such as sort orders, filters and session IDs. Calendars and faceted search are the usual traps, because every page links to yet another generated URL.
Related pages
Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.