Concurrent Web Crawler

Overview

Build a crawler that starts from a seed URL, fetches the page, extracts its links, and keeps following them — using many worker threads to fetch in parallel. Each URL is visited at most once, and the crawl is usually restricted to a single domain.

The fetching is I/O-bound, so concurrency pays off, but the shared state is the catch. A thread-safe frontier (the queue of URLs to visit) hands work to idle workers, and a thread-safe visited set prevents two threads from crawling the same page. The subtle part is knowing when the crawl is done — the queue being momentarily empty isn't enough while workers are still discovering links.

Interfaces

class WebCrawler:
    def __init__(self, num_workers, same_domain_only=True):
        self._frontier = Queue()          # URLs waiting to be fetched
        self._visited = set()
        self._lock = Lock()               # guards _visited
        self._num_workers = num_workers

    def crawl(self, seed_url):
        """Crawl from seed_url and return the set of pages visited."""
        ...

    def _worker(self):
        # pull a URL, fetch it, enqueue new same-domain links not yet seen
        ...

    def _claim(self, url):
        """Atomically mark url visited; return False if already seen."""
        with self._lock:
            if url in self._visited:
                return False
            self._visited.add(url)
            return True

Workers loop: take a URL from the frontier, fetch it, parse out links, and for each unseen same-domain link, mark it visited and enqueue it. Marking-and-checking must be one atomic step so two workers never both claim the same URL.

Inputs and outputs

  • Input: a seed URL, a worker count, and a domain restriction.
  • Output: the set of pages successfully visited, each fetched exactly once.

Requirements

  • Fetching runs across num_workers threads in parallel.
  • Each URL is fetched at most once — claim it atomically before enqueuing.
  • Only in-scope links (e.g. same domain) are followed.
  • Termination is detected correctly: stop when the frontier is empty and no worker is still processing.
  • Network errors on one page don't crash the crawl.

Examples

c = WebCrawler(num_workers=8, same_domain_only=True)
visited = c.crawl("https://example.com")

# fetches example.com, follows its internal links across 8 threads,
# skips external domains, and visits each internal page exactly once

Think about it

A worker just pulled the last URL off the frontier — but it's about to discover ten more links on that page.

If you stop the moment the queue looks empty, what do you miss?

How do you track the work still in-flight so you only stop when there's truly nothing left?

Follow-ups

  • How would you add politeness — per-domain rate limits and robots.txt — without leaving all your workers idle?

  • Some fetches fail transiently. How would you retry with backoff without crawling the same page twice?

  • How would you move the visited set and frontier out of memory (say, into Redis) so the crawl scales across machines?

  • And more...

FANG Crash Course

Fullset Newest FANG Coding/System Design Questions, Optimal Solutions and Explanations.

Step_1 Matched with a FAANG+ Senior Engineer

Step_2 Fullset FANG Coding/System Design Questions

Step_3 Solutions + step by step Explanation

Check it out