DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building a Resilient Web Crawl System: Design, Implementation, and Observability

Building a Resilient Web Crawl System: Design, Implementation, and Observability

Building a Resilient Web Crawl System: Design, Implementation, and Observability

Web crawling is a foundational capability for search engines, data platforms, and competitive intelligence. A robust crawl system must be scalable, fault-tolerant, respectful of site policies, and easy to operate. This tutorial walks through the end-to-end process of designing and implementing a resilient web crawler, with concrete code examples, deployment tips, and practical guidance you can adapt to your organization.

1) Define the crawl goals and constraints

Before touching code, clarify what you’re trying to achieve and what you won’t do.

  • Goals

    • Maximize coverage of target domains within a given latency bound.
    • Respect robots.txt, rate limits, and politeness policies.
    • Store structured metadata (URL, status, fetch time, response codes, error messages).
    • Provide observability dashboards for operator sanity and alerting.
  • Constraints

    • Crawl budget per domain (requests per second, total daily requests).
    • Maximum depth and breadth from seed URLs.
    • Handling of dynamic content (e.g., JavaScript-rendered pages) vs. static content.
    • Data freshness requirements (e.g., re-crawl every 24-72 hours).
  • Success metrics

    • Coverage (percentage of target URLs visited).
    • Freshness (time since last successful fetch).
    • Error rate (non-2xx responses, hard 4xx/5xx errors).
    • Latency (time from seed to crawl completion per URL).

Illustration: Think of a crawl system as a fleet of robots with different routes. You need a map (crawl plan), traffic rules (politeness), and a control tower (observability) to keep them running smoothly.

2) High-level architecture

A practical, modular architecture helps you scale and operate safely.

  • Scheduler/Queue

    • Manages work units (URLs) with priorities and per-domain quotas.
    • Supports backoff, retries, and politeness windows.
  • Fetcher

    • Performs HTTP requests with robust error handling.
    • Applies per-domain rate limiting and user-agent strategies.
    • Handles redirects, compression, and timeouts.
  • Parser/Normalizer

    • Extracts links, metadata, and relevant content.
    • Normalizes URLs to avoid duplicates.
  • Storage

    • Durable store for crawl metadata (URL, status, timestamp, content hash).
    • Optional content storage for fetched HTML or extracted data.
  • Deduplication and Canonicalization

    • Avoids re-crawling identical URLs and handles canonical links.
  • Observability

    • Metrics, logs, dashboards, and alerting.
  • Compliance and Ethics

    • robots.txt fetcher, crawl-delay respect, and opt-out handling.

Visualizing: A message-driven pipeline where a URL message flows through fetch, parse, store, and metrics reporters, with per-domain throttling and error handling woven in.

3) Data model essentials

A lightweight schema helps keep the system maintainable.

  • CrawlJob (URL to fetch)

    • id: string (UUID)
    • url: string
    • domain: string
    • priority: int
    • created_at: timestamp
    • updated_at: timestamp
    • status: enum (QUEUED, FETCHING, PARSING, SUCCESS, RETRY, BLOCKED, FAILED)
    • retry_count: int
    • max_retries: int
    • last_fetched_at: timestamp
    • http_status: int
    • content_hash: string
    • content_type: string
    • redirect_count: int
    • error_message: string
  • DomainPolicy

    • domain: string
    • rate_limit_per_s: float
    • concurrency_limit: int
    • robots_txt_last_checked: timestamp
    • robots_txt_disallow: boolean
    • crawl_delay_seconds: float
    • user_agent: string
  • CrawlContent (optional)

    • job_id: string
    • html: text/blob
    • extracted_links: list
    • title: string
    • meta_description: string

Choose a storage approach you’re comfortable with (PostgreSQL for relational integrity, or a NoSQL store like Redis for speed with a separate metadata store).

4) Core components: code sketches

Below are concise Python-centric sketches to illustrate key components. You can adapt to your language of choice (Go, Rust, Java, etc.).

  • URL queue with per-domain rate limiting (simplified)
### crawl_queue.py
import time
from collections import defaultdict, deque

class CrawlQueue:
    def __init__(self):
        self.domain_queues = defaultdict(deque)
        self.domain_last_access = defaultdict(float)
        self.domain_limits = {}  # domain -> (limit_per_sec, max_concurrency)
    def add(self, job):
        domain = self._extract_domain(job.url)
        self.domain_queues[domain].append(job)
    def next_batch(self, batch_size=32):
        now = time.time()
        batch = []
        for domain, q in list(self.domain_queues.items()):
            limit = self.domain_limits.get(domain, (1.0, 2))
            last = self.domain_last_access.get(domain, 0)
            if len(batch) >= batch_size:
                break
            if now - last < 1.0 / max(limit, 1e-6):
                continue
            if q:
                batch.append(q.popleft())
                self.domain_last_access[domain] = now
        return batch

    @staticmethod
    def _extract_domain(url):
        from urllib.parse import urlparse
        return urlparse(url).netloc
Enter fullscreen mode Exit fullscreen mode
  • Fetching with timeout and polite headers
### fetcher.py
import requests
from urllib.parse import urlparse

DEFAULT_HEADERS = {
    "User-Agent": "MyCrawlBot/1.0 (+https://example.com/crawler)"
}

def fetch(url, timeout=15, headers=None, max_redirects=5):
    h = dict(DEFAULT_HEADERS)
    if headers:
        h.update(headers)
    try:
        resp = requests.get(url, headers=h, timeout=timeout, allow_redirects=True, timeout_connect=5, max_redirects=max_redirects)
        return {
            "status": resp.status_code,
            "content_type": resp.headers.get("Content-Type", ""),
            "content": resp.content,
            "final_url": resp.url,
            "elapsed": resp.elapsed.total_seconds(),
        }
    except requests.RequestException as e:
        return {"status": None, "error": str(e)}
Enter fullscreen mode Exit fullscreen mode
  • Simple link extractor
### parser.py
from bs4 import BeautifulSoup
from urllib.parse import urljoin

def extract_links(html, base_url):
    soup = BeautifulSoup(html, "html.parser")
    links = set()
    for a in soup.find_all("a", href=True):
        href = a["href"]
        if href.startswith("#"):
            continue
        href = urljoin(base_url, href)
        links.add(href)
    title = (soup.title.string if soup.title else "")
    meta = ""
    m = soup.find("meta", attrs={"name": "description"})
    if m and m.get("content"):
        meta = m["content"]
    return {
        "links": list(links),
        "title": title,
        "meta_description": meta
    }
Enter fullscreen mode Exit fullscreen mode
  • Simple storage interface (pseudocode)
### storage.py
class Storage:
    def __init__(self, db_url):
        pass  # initialize connection

    def upsert_job(self, job):
        pass  # insert or update

    def mark_fetched(self, job_id, status, http_status, content_hash, redirect_count, error_message=""):
        pass

    def store_content(self, job_id, html, links, title, meta_description):
        pass
Enter fullscreen mode Exit fullscreen mode

Notes:

  • In production, you’d implement idempotent deduplication, content hashing (e.g., SHA-256) to detect re-crawls, and robust retry/backoff strategies.
  • Use a robust queue system (e.g., RabbitMQ, Kafka, or a Redis-backed queue) for durability and scalability. ### 5) Politeness, robots.txt, and policy handling

Respecting sites is critical for ethical crawling.

  • Fetch robots.txt at domain startup and respect Disallow rules.
  • Implement crawl-delay per domain, with a maximum cap.
  • Use a dynamic user-agent with contact info or a crawl-privacy header if required.
  • Honor "Retry-After" from 429 or 503 responses.

Implementation tips:

  • Maintain a robots.txt cache per domain with a TTL (e.g., 24 hours).
  • Before fetching a URL, verify it’s allowed by robots.txt, and that you’re within the domain’s rate limits.
  • If robots.txt disallows or a site imposes a long delay, skip or back off accordingly. ### 6) Observability and reliability

Observability turns a crawler from a script into a trusted system.

  • Metrics to collect
    • Requests per second per domain
    • Success/error rate
    • Average fetch latency
    • 4xx/5xx breakdown
    • Depth and breadth crawled
    • Queue backlogs and retry counts
  • Logging
    • Structured logs with request_id, job_id, domain, status, and error context.
  • Dashboards
    • Real-time views for active domains, top error sources, and crawl progress.
  • Alerting
    • Thresholds for high error rates, prolonged idleness, or sustained backoffs.

Example: A simple Prometheus-based setup could expose counters and histograms, with Grafana dashboards to visualize trends over time.

7) Scaling strategies

As your crawl footprint grows, consider these approaches.

  • Partition by domain
    • Each domain or a set of domains gets a dedicated worker pool to enforce per-domain limits naturally.
  • Sharding the queue
    • Use multiple queue instances or a Kafka topic with consumer groups to scale horizontally.
  • Backoff and retries
    • Implement exponential backoff with jitter to reduce coordinated retries during global events.
  • Content storage tiering
    • Store raw HTML for a limited window (e.g., 7 days) and archive older content to cheaper storage if needed.

Practical tip: Start with a single, reliable crawl path and gradually shard as you observe bottlenecks in fetch throughput or storage I/O.

8) Testing and safety nets

  • Unit tests for fetch resilience (timeouts, redirects, error handling).
  • Integration tests against a mock server that simulates robots.txt and crawlable pages.
  • Canary crawls
    • Run in a dry run mode that logs decisions without performing network requests.
  • Safeguards

    • Rate limit enforcement at the application layer even if the network shows short-term bursts.
    • Blacklist misbehaving domains and implement a grace period for reintroduction. ### 9) Deployment and operations
  • Deploy in a containerized environment (Docker) or serverless workers for per-domain parallelism.

  • Use environment-specific configurations (staging vs. production) for domain policies and backoffs.

  • Automated health checks

    • Verify that the fetcher can reach target hosts, robots.txt caches refresh, and the queue is progressing.
  • Rollbacks

    • Maintain versioned migrations for schema changes and a feature flag for major crawl logic changes. ### 10) A hands-on minimal end-to-end example

Here’s how a minimal end-to-end run would look conceptually:

  • Seed URLs are inserted into the crawl queue with priority.
  • A worker pool fetches batches, respecting per-domain rate limits.
  • Fetched pages are parsed to extract new links; new URLs are enqueued with updated priorities.
  • The results are stored: status codes, content hashes, and extracted metadata.
  • Observability pipelines publish metrics and logs to your dashboards.

Sample snippet to tie components together (pseudocode):

def run_crawl_cycle():
    queue = CrawlQueue()
    storage = Storage(db_url="postgres://...")
    policy = DomainPolicyStore()

    batch = queue.next_batch(32)
    for job in batch:
        dom_policy = policy.get(job.domain)
        if not dom_policy.is_allowed():
            job.status = "BLOCKED"
            storage.upsert_job(job)
            continue

        result = fetch(job.url)
        job.last_fetched_at = current_ts()
        if result["status"] == 200:
            parsed = parse_html(result["content"], result["final_url"])
            storage.store_content(job.id, result["content"], parsed["links"], parsed["title"], parsed["meta_description"])
            for link in parsed["links"]:
                new_job = CrawlJob(url=link, domain=extract_domain(link), priority=job.priority+1)
                storage.upsert_job(new_job)
            storage.mark_fetched(job.id, "SUCCESS", result["status"], hash(result["content"]), result.get("redirect_count", 0))
        else:
            storage.mark_fetched(job.id, "RETRY" if job.retry_count < job.max_retries else "FAILED", result.get("status"), None, result.get("redirect_count", 0), error_message=result.get("error"))
Enter fullscreen mode Exit fullscreen mode

This sketch emphasizes flow, not production readiness. Treat it as a blueprint to tailor to your tech stack and scale requirements.

Final thoughts

A resilient web crawl system is less about clever single-line tricks and more about thoughtful architecture, disciplined policy, and strong observability. Start with clear goals, implement a robust per-domain control plane, and invest in monitoring and safety nets. As you gain confidence, you can layer advanced features like JavaScript rendering with headless browsers, adaptive crawl budgeting using machine learning signals, or distributed scheduling with backpressure-aware queues.

If you’d like, tell me your preferred tech stack (language, storage, queue system) and target scale, and I’ll tailor a concrete starter kit with runnable code and deployment manifests. Would you prefer a Python-based stack with PostgreSQL and Redis, or something differently aligned with your existing infrastructure?

-

Rizwan Saleem | https://rizwansaleem.co

Sources

Top comments (0)