system-design24 min read

How to Design a Web Crawler System — A Senior+ Guide | Ayodhyya

How to Design a Web Crawler System

Building Google-scale web crawling: distributed fetching, URL frontier management, and politeness at scale

Senior+ Guide 45+ min read 10,000+ words Ayodhyya

Table of Contents

  1. Introduction — What Is a Web Crawler?
  2. Functional & Non-Functional Requirements
  3. Capacity Estimation & Back-of-Envelope
  4. Data Model & Storage Schema
  5. API Design
  6. High-Level Architecture
  7. URL Frontier & Priority Queue
  8. Web Fetching Pipeline
  9. URL Filtering & Deduplication
  10. Politeness & robots.txt
  11. HTML Parsing & Content Extraction
  12. Distributed Architecture
  13. Content Storage & Indexing
  14. Re-crawling & Freshness
  15. DNS Resolution at Scale
  16. Error Handling & Resilience
  17. Monitoring & Observability
  18. Security & Anti-Abuse
  19. Case Studies — Production Crawlers
  20. Cost Estimation
  21. Edge Cases
  22. Interview Q&A
  23. Conclusion

1. Introduction — What Is a Web Crawler?

A web crawler (also called a spider, bot, or web robot) is an automated program that systematically browses the World Wide Web, downloading pages and extracting links for further crawling. Google's web crawler processes over 100 billion pages per day, consuming 1 petabyte of data daily. Bing crawls approximately 10 billion pages. The Common Crawl dataset, which is publicly available, contains 3.1 billion web pages and uses 250 TB of storage.

Web crawlers are the foundation of search engines, but they serve many other purposes: monitoring website changes, competitive intelligence, price comparison, academic research, content aggregation, and training machine learning models. Building a web crawler at scale requires solving several distributed systems challenges: managing billions of URLs in a priority queue, politeness constraints (not overwhelming servers), duplicate detection, distributed coordination, and fault tolerance across thousands of machines.

Interview Context: The web crawler design question tests your understanding of distributed crawling, URL management, deduplication at scale, and politeness constraints. It is a classic system design question frequently asked at Google, Microsoft, Amazon, and other companies with search or data acquisition teams.

2. Functional & Non-Functional Requirements

Functional Requirements

#RequirementPriorityDetails
F1Seed URL crawlingMustStart from seed URLs and follow links recursively
F2PolitenessMustRespect robots.txt, rate limit per domain
F3URL deduplicationMustDon't crawl the same URL twice
F4Content deduplicationShouldDetect mirror/duplicate content pages
F5Re-crawlingMustPeriodically re-crawl pages for freshness
F6URL priorityShouldHigh-priority pages crawled more frequently
F7Content storageMustStore crawled HTML, metadata, extracted text
F8Domain-level managementShouldTrack per-domain crawl state and stats
F9Link extractionMustExtract and normalize all links from pages
F10Partial crawlingNiceCrawl only sections of pages (CSS selectors)

Non-Functional Requirements

RequirementTargetRationale
Crawl rate1 billion pages/dayGoogle-scale crawl for comprehensive index
Crawl latency< 1 hour from discovery to crawlContent freshness for time-sensitive pages
Availability99.9%Crawler can tolerate brief downtime
StoragePetabyte-scaleStoring billions of HTML pages
Politeness< 1 request/second per domainAvoid overloading target servers
Dedup accuracy> 99% dedup rateMinimize wasted crawl resources

3. Capacity Estimation & Back-of-Envelope

Daily Crawl Volume

MetricCalculationResult
Pages to crawl per dayGiven1 billion pages
Average page size500 KB (HTML + embedded resources)500 KB
Daily download volume1B × 500 KB500 TB/day
Average QPS1B / 86,400~11,600 pages/second
Peak QPS (3x)11,600 × 3~35,000 pages/second
Unique domainsEstimated active domains~200 million
Pages per domain per day1B / 200M~5 pages/domain/day
Links per page (average)5050 billion new URLs discovered/day

Storage Estimates

DataSize per entryCount/dayDailyAnnual
Raw HTML500 KB1B pages500 TB182 PB
Extracted text50 KB1B pages50 TB18 PB
URL metadata1 KB50B URLs50 TB18 PB
Bloom filter10 bits/URL10B URLs1.25 GB456 GB
robots.txt cache5 KB200M domains1 TB (one-time)1 TB

Bandwidth Estimates

MetricCalculationResult
Inbound bandwidth500 TB / 86,400 sec~5.8 GB/sec (46 Gbps)
Outbound (links + metadata)50 TB / 86,400 sec~580 MB/sec (4.6 Gbps)
Total per crawler node46 Gbps / 1000 nodes~46 Mbps per node

4. Data Model & Storage Schema

Entity Relationship

erDiagram CRAWLED_PAGE { bigint id PK varchar url varchar domain text content_hash text content int content_length datetime crawled_at int http_status varchar content_type } URL_ENTRY { bigint id PK varchar url varchar domain int priority varchar status datetime discovered_at datetime last_crawled_at int crawl_count varchar crawl_group } DOMAIN_INFO { varchar domain PK int robots_disallow_count int crawl_delay datetime last_crawl_at int pages_crawled_today varchar ip_address } CRAWL_JOB { bigint id PK varchar crawler_id varchar status datetime started_at datetime completed_at int pages_crawled int pages_failed } URL_ENTRY ||--o{ CRAWLED_PAGE : "produces" DOMAIN_INFO ||--o{ URL_ENTRY : "owns" CRAWL_JOB ||--o{ URL_ENTRY : "processes"

Cassandra Schema (URL Frontier — Write-Optimized)

CQL
CREATE TABLE url_frontier (
    crawl_group text,        -- Priority group: "high", "medium", "low"
    domain text,             -- For politeness grouping
    url text,
    priority int,            -- Within-group priority
    status text,             -- 'pending', 'crawling', 'completed', 'failed'
    discovered_at timestamp,
    last_crawled_at timestamp,
    next_crawl_at timestamp, -- When this URL should be crawled next
    crawl_count int,
    content_hash text,       -- For deduplication
    PRIMARY KEY (crawl_group, domain, url)
) WITH CLUSTERING ORDER BY (domain ASC, url ASC);

CREATE TABLE crawled_content (
    url_hash text,           -- SHA-256 of URL
    url text,
    domain text,
    crawled_at timestamp,
    http_status int,
    content_type text,
    content blob,            -- Raw HTML
    extracted_text text,     -- Cleaned text content
    links list<text>,        -- Extracted outbound links
    content_hash text,       -- For content dedup
    metadata map<text, text>,
    PRIMARY KEY (url_hash, crawled_at)
) WITH CLUSTERING ORDER BY (crawlled_at DESC)
  AND default_time_to_live = 7776000;  -- 90 days TTL

CREATE TABLE domain_state (
    domain text PRIMARY KEY,
    robots_txt blob,
    robots_disallow_paths list<text>,
    crawl_delay int,         -- seconds between requests
    last_crawl_at timestamp,
    pages_crawled_today int,
    daily_limit int,
    avg_response_time_ms int,
    error_rate float
);
            

Redis Structures

C#
// URL deduplication bloom filter (Redis module)
// FP rate: 0.1% with 10 bits per URL for 10 billion URLs
redis.call('BF.RESERVE', 'url_bloom', 0.001, 10000000000);
redis.call('BF.ADD', 'url_bloom', normalizedUrl);

// Domain crawl rate limiting
// Key: domain_rate:{domain}
// Sorted set with timestamps for sliding window
redis.call('ZADD', 'domain_rate:example.com', timestampMs, requestId);
redis.call('ZREMRANGEBYSCORE', 'domain_rate:example.com', 0, timestampMs - 1000);

// Crawl queue priorities (Redis Sorted Sets)
// Score = priority + time_decay
redis.call('ZADD', 'crawl_queue:high', score, url);
redis.call('ZPOPMIN', 'crawl_queue:high');
            

5. API Design

External API

HTTP
// Submit URLs for crawling
POST /api/v1/crawl/submit
{
    "urls": [
        "https://example.com/page1",
        "https://example.com/page2"
    ],
    "priority": "high",
    "callback_url": "https://your-app.com/webhook/crawl-complete",
    "metadata": {
        "campaign": "product_launch",
        "tag": "competitor_analysis"
    }
}

// Check crawl status
GET /api/v1/crawl/{job_id}/status

// Get crawled content
GET /api/v1/crawl/{job_id}/content?page=1&format=json

// List all crawl jobs
GET /api/v1/crawl/jobs?status=completed&limit=20

// Delete/cancel crawl job
DELETE /api/v1/crawl/{job_id}

// Get crawl statistics
GET /api/v1/stats/domains?top=100
            

Internal APIs

HTTP
// Worker fetches next URL from frontier
GET /internal/v1/frontier/next?worker_id=crawler-42&count=100

// Worker reports crawl result
POST /internal/v1/crawl/result
{
    "url": "https://example.com/page1",
    "worker_id": "crawler-42",
    "status": "success",
    "http_status": 200,
    "content_length": 45230,
    "content_hash": "sha256:abc123...",
    "links_found": [
        "https://example.com/page2",
        "https://other.com/page1"
    ],
    "crawl_time_ms": 1250
}

// DNS resolver service
POST /internal/v1/dns/resolve
{
    "domains": ["example.com", "other.com"]
}

// robots.txt fetcher
GET /internal/v1/robots/{domain}
            

6. High-Level Architecture

flowchart TB subgraph Input["Input Layer"] SEED[Seed URLs] API[URL Submission API] end subgraph Frontier["URL Frontier"] PRIORITY[Priority Queue] DOMAIN_Q[Domain Queue] DEDUP[URL Deduplication] end subgraph Fetchers["Fetcher Layer (Distributed)"] F1[Fetcher Worker 1] F2[Fetcher Worker 2] F3[Fetcher Worker N] end subgraph Processing["Processing Layer"] PARSE[HTML Parser] EXTRACT[Link Extractor] CHECK[robots.txt Checker] end subgraph Storage["Storage Layer"] CONTENT[(Content Store)] URL_DB[(URL Metadata)] DOMAIN_DB[(Domain State)] end subgraph Recrawl["Re-crawl Scheduler"] SCHED[Crawl Scheduler] FRESH[Freshness Tracker] end SEED & API --> DEDUP --> PRIORITY --> DOMAIN_Q DOMAIN_Q --> F1 & F2 & F3 F1 & F2 & F3 --> PARSE --> EXTRACT EXTRACT --> DEDUP F1 & F2 & F3 --> CONTENT CHECK --> DOMAIN_DB SCHED --> PRIORITY

Component Responsibilities

ComponentResponsibilityScaling Strategy
URL FrontierPriority queue managing billions of URLsDistributed across nodes using consistent hashing
Fetcher WorkersDownload web pages, respect politenessHorizontal scaling (1000+ workers)
URL DedupBloom filter + URL normalizationDistributed bloom filter, local + global
HTML ParserExtract text, links, metadataEmbedded in fetcher workers
robots.txt CheckerCache and enforce crawl rulesRedis cache + periodic refresh
Content StorePersist crawled contentHDFS / S3 with partitioning by date
Re-crawl SchedulerDetermine when to re-crawl pagesSeparate service with priority calculation
'@

7. URL Frontier & Priority Queue

Frontier Architecture

flowchart TB subgraph Frontier["URL Frontier (Distributed)"] subgraph Priority["Priority Queues"] P1[High Priority] P2[Medium Priority] P3[Low Priority] end subgraph DomainQueues["Per-Domain Queues"] D1[example.com] D2[other.com] D3[third.com] end end F[Fetcher Worker] --> P1 P1 --> D1 P1 --> D2 P1 --> D3

Priority Calculation

C#
public class UrlPriorityCalculator
{
    public int CalculatePriority(UrlEntry url, DomainState domain)
    {
        int score = 50; // Base score

        // PageRank factor (if known)
        if (url.PageRank.HasValue)
            score += (int)(url.PageRank.Value * 30); // 0-30 points

        // Freshness factor � how recently was it updated?
        if (url.LastCrawledAt.HasValue)
        {
            var age = DateTime.UtcNow - url.LastCrawledAt.Value;
            if (age < TimeSpan.FromHours(1))
                score += 20; // Very fresh, crawl again soon
            else if (age < TimeSpan.FromDays(1))
                score += 10; // Moderately fresh
            else if (age > TimeSpan.FromDays(30))
                score -= 10; // Stale, lower priority
        }

        // Domain authority
        if (domain.IsHighAuthority)
            score += 10;

        // Change frequency � pages that change often get higher priority
        if (url.ChangeFrequency == "daily")
            score += 15;
        else if (url.ChangeFrequency == "weekly")
            score += 5;

        // Link depth � shallower pages are higher priority
        score -= url.DepthFromSeed * 2;

        // Error penalty � URLs that recently failed get lower priority
        if (url.ConsecutiveFailures > 3)
            score -= 20;

        return Math.Clamp(score, 0, 100);
    }
}
            

Frontier Storage Design

Design ChoiceImplementationTrade-off
In-memory queuePriority queue in each workerFast but not durable, lost on crash
Redis sorted setsPriority = score, URL = memberFast, shared, but memory-limited
Cassandra-backedPartitioned by domain + priorityDurable, scalable, but higher latency
Local disk + distributedWrite-ahead log per workerBest throughput, complex recovery

8. Web Fetching Pipeline

sequenceDiagram participant W as Worker participant F as URL Frontier participant R as robots.txt Cache participant D as DNS Cache participant H as HTTP Client participant S as Content Store participant E as Link Extractor W->>F: Get next URL F-->>W: URL + metadata W->>R: Check robots.txt R-->>W: Allowed? + delay W->>D: Resolve DNS D-->>W: IP address W->>H: HTTP GET H-->>W: HTML response W->>S: Store content W->>E: Extract links E-->>W: New URLs W->>F: Submit new URLs W->>F: Report crawl result

Fetcher Implementation

C#
public class WebFetcher : BackgroundService
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly IFrontierService _frontier;
    private readonly IRobotsChecker _robotsChecker;
    private readonly IDnsCache _dnsCache;
    private readonly IContentStore _contentStore;
    private readonly ILinkExtractor _linkExtractor;
    private readonly IDeduplicator _deduplicator;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var urlEntry = await _frontier.GetNextUrlAsync(
                workerId: _workerId,
                batchSize: 10);

            foreach (var entry in urlEntry)
            {
                try
                {
                    await CrawlUrlAsync(entry, stoppingToken);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Failed to crawl {Url}", entry.Url);
                    await _frontier.ReportFailureAsync(entry);
                }
            }
        }
    }

    private async Task CrawlUrlAsync(UrlEntry entry, CancellationToken ct)
    {
        var domain = entry.Domain;

        // 1. Check robots.txt
        var robotsResult = await _robotsChecker.CheckAsync(domain, entry.Url);
        if (!robotsResult.Allowed)
        {
            _logger.LogInformation("Blocked by robots.txt: {Url}", entry.Url);
            await _frontier.SkipAsync(entry, "robots.txt disallowed");
            return;
        }

        // 2. Respect crawl delay
        if (robotsResult.CrawlDelay > 0)
        {
            await Task.Delay(TimeSpan.FromSeconds(robotsResult.CrawlDelay), ct);
        }

        // 3. Rate limit per domain
        if (!await _rateLimiter.AllowAsync(domain))
        {
            await _frontier.RequeueAsync(entry);
            return;
        }

        // 4. DNS resolution
        var ip = await _dnsCache.ResolveAsync(domain);

        // 5. Fetch the page
        var client = _httpClientFactory.CreateClient();
        client.DefaultRequestHeaders.Add("User-Agent", _config.CrawlerUserAgent);
        client.Timeout = TimeSpan.FromSeconds(30);

        var response = await client.GetAsync(entry.Url, ct);

        if (!response.IsSuccessStatusCode)
        {
            await _frontier.ReportFailureAsync(entry, response.StatusCode);
            return;
        }

        var html = await response.Content.ReadAsStringAsync(ct);
        var contentHash = ComputeHash(html);

        // 6. Content deduplication
        if (await _deduplicator.IsDuplicateContentAsync(contentHash))
        {
            _logger.LogInformation("Duplicate content: {Url}", entry.Url);
            await _frontier.SkipAsync(entry, "duplicate content");
            return;
        }

        // 7. Store content
        await _contentStore.StoreAsync(new CrawledPage
        {
            Url = entry.Url,
            Domain = domain,
            Content = html,
            ContentHash = contentHash,
            CrawledAt = DateTime.UtcNow,
            HttpStatusCode = (int)response.StatusCode,
            ContentLength = html.Length
        });

        // 8. Extract links
        var links = _linkExtractor.ExtractLinks(html, entry.Url);
        var newUrls = new List<string>();

        foreach (var link in links)
        {
            var normalized = NormalizeUrl(link, entry.Url);
            if (normalized != null && !await _deduplicator.IsDuplicateUrlAsync(normalized))
            {
                newUrls.Add(normalized);
            }
        }

        // 9. Submit new URLs to frontier
        if (newUrls.Any())
        {
            await _frontier.SubmitUrlsAsync(newUrls, discoveredFrom: entry.Url);
        }

        // 10. Report success
        await _frontier.ReportSuccessAsync(entry, linksFound: links.Count);
    }
}
            

9. URL Filtering & Deduplication

URL Normalization

RuleBeforeAfter
Lowercase scheme and hostHTTP://Example.COM/Pagehttp://example.com/page
Remove default portshttp://example.com:80/pagehttp://example.com/page
Remove trailing slashhttp://example.com/page/http://example.com/page
Remove fragmenthttp://example.com/page#sectionhttp://example.com/page
Remove session IDshttp://example.com/page?sid=abc123http://example.com/page
Decode percent-encodinghttp://example.com/pa%67ehttp://example.com/page
Sort query parametershttp://example.com/page?b=2&a=1http://example.com/page?a=1&b=2
Remove tracking paramshttp://example.com/page?utm_source=googlehttp://example.com/page

Two-Level Deduplication

C#
public class TwoLevelDeduplicator
{
    private readonly IBloomFilter _bloomFilter;       // Level 1: Fast probabilistic check
    private readonly IContentHashStore _contentStore;  // Level 2: Exact check

    // Level 1: URL deduplication using bloom filter
    public async Task<bool> IsDuplicateUrlAsync(string normalizedUrl)
    {
        // Bloom filter: O(1) check, 0.1% false positive rate
        if (!_bloomFilter.MightContain(normalizedUrl))
        {
            return false; // Definitely not seen before
        }

        // False positive � confirm with exact check
        return await _contentStore.UrlExistsAsync(normalizedUrl);
    }

    // Level 2: Content deduplication using SimHash
    public async Task<bool> IsDuplicateContentAsync(string contentHash)
    {
        var simHash = ComputeSimHash(contentHash);

        // Find similar hashes within Hamming distance threshold
        var similarHashes = await _contentStore.FindSimilarHashesAsync(simHash, threshold: 3);

        if (similarHashes.Count == 0)
            return false;

        // For each similar hash, do full comparison
        foreach (var existingHash in similarHashes)
        {
            if (ComputeHammingDistance(simHash, existingHash) <= 3)
            {
                return true; // Content is similar enough to be a duplicate
            }
        }

        return false;
    }

    private ulong ComputeSimHash(string content)
    {
        var tokens = Tokenize(content);
        var vector = new int[64]; // 64-bit SimHash

        foreach (var token in tokens)
        {
            var hash = HashFunction(token);
            for (int i = 0; i < 64; i++)
            {
                vector[i] += ((hash >> i) & 1) == 1 ? 1 : -1;
            }
        }

        ulong simHash = 0;
        for (int i = 0; i < 64; i++)
        {
            if (vector[i] > 0)
                simHash |= (1UL << i);
        }

        return simHash;
    }
}
            

Deduplication Performance

MethodSpaceTimeAccuracyUse Case
Bloom filter10 bits/URLO(1)99.9% (0.1% FP)URL dedup, first pass
SimHash8 bytes/contentO(1)~95% (near-duplicates)Content dedup
Exact hash (SHA-256)32 bytes/contentO(n)100%Exact content dedup
URL set (Redis)200 bytes/URLO(1)100%Precise dedup, small sets
Local bloom + global setVariableO(1)99.9%Best of both worlds

10. Politeness & robots.txt

robots.txt Parsing

C#
public class RobotsTxtChecker
{
    private readonly IDistributedCache _cache;

    public async Task<RobotsResult> CheckAsync(string domain, string url)
    {
        var robots = await GetOrFetchRobotsTxtAsync(domain);

        if (robots == null)
            return new RobotsResult { Allowed = true }; // No robots.txt = everything allowed

        // Check if URL matches any Disallow pattern
        var path = new Uri(url).AbsolutePath;
        foreach (var disallowPath in robots.DisallowPaths)
        {
            if (path.StartsWith(disallowPath, StringComparison.OrdinalIgnoreCase))
            {
                return new RobotsResult
                {
                    Allowed = false,
                    Reason = $"Disallowed by robots.txt: {disallowPath}"
                };
            }
        }

        // Check crawl-delay
        var crawlDelay = robots.CrawlDelay ?? 1; // Default 1 second

        return new RobotsResult
        {
            Allowed = true,
            CrawlDelay = crawlDelay,
            SitemapUrl = robots.SitemapUrl
        };
    }

    private async Task<RobotsTxt> GetOrFetchRobotsTxtAsync(string domain)
    {
        var cacheKey = $"robots:{domain}";
        var cached = await _cache.GetAsync(cacheKey);

        if (cached != null)
            return JsonSerializer.Deserialize<RobotsTxt>(Encoding.UTF8.GetString(cached));

        // Fetch robots.txt
        try
        {
            var url = $"https://{domain}/robots.txt";
            var response = await _httpClient.GetAsync(url, TimeSpan.FromSeconds(5));

            if (response.IsSuccessStatusCode)
            {
                var content = await response.Content.ReadAsStringAsync();
                var parsed = ParseRobotsTxt(content);

                // Cache for 24 hours
                await _cache.SetAsync(cacheKey,
                    Encoding.UTF8.GetBytes(JsonSerializer.Serialize(parsed)),
                    new DistributedCacheEntryOptions
                    {
                        AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
                    });

                return parsed;
            }
        }
        catch { }

        return null; // Fetch failed, assume allowed
    }
}
            

Politeness Rules

RuleImplementationDefault
robots.txt complianceCheck before every crawlAlways enforced
Crawl delayPer-domain rate limiting1 second between requests
Concurrent connectionsMax 1 connection per domainPrevents connection flooding
User-Agent identificationSend identifying User-AgentCompanyBot/1.0 (+https://company.com/bot)
Daily page limitMax pages per domain per day10,000 pages/domain/day
Off-peak crawlingCrawl during target server off-peakAnalyze server response times
Back off on errorsExponential backoff on 429/503Start at 5s, max 1 hour
Respect Crawl-delayHonor robots.txt Crawl-delay directivePer-domain configurable
Why Politeness Matters: In 2011, Google was sued by a French luxury company for over-crawling their website and causing server overload. In 2020, LinkedIn sued hiQ Labs for scraping public profiles. Violating robots.txt or overloading servers can lead to legal action, IP blacklisting, and reputational damage. Always crawl respectfully.

11. HTML Parsing & Content Extraction

Parsing Pipeline

C#
public class HtmlContentExtractor
{
    public ExtractedContent Extract(string html, string baseUrl)
    {
        var doc = new HtmlDocument();
        doc.LoadHtml(html);

        var content = new ExtractedContent
        {
            Title = ExtractTitle(doc),
            MetaDescription = ExtractMetaDescription(doc),
            TextContent = ExtractTextContent(doc),
            Links = ExtractLinks(doc, baseUrl),
            Images = ExtractImages(doc, baseUrl),
            Metadata = ExtractMetadata(doc),
            Language = DetectLanguage(doc),
            CanonicalUrl = ExtractCanonicalUrl(doc)
        };

        return content;
    }

    private List<string> ExtractLinks(HtmlDocument doc, string baseUrl)
    {
        var links = new List<string>();

        foreach (var anchor in doc.DocumentNode.SelectNodes("//a[@href]") ?? Enumerable.Empty<HtmlNode>())
        {
            var href = anchor.GetAttributeValue("href", "");
            if (string.IsNullOrEmpty(href)) continue;

            // Resolve relative URLs
            var absoluteUrl = new Uri(new Uri(baseUrl), href).ToString();

            // Filter out non-HTTP links (mailto:, javascript:, tel:)
            if (absoluteUrl.StartsWith("http://") || absoluteUrl.StartsWith("https://"))
            {
                links.Add(absoluteUrl);
            }
        }

        return links.Distinct().ToList();
    }

    private string ExtractTextContent(HtmlDocument doc)
    {
        // Remove script, style, nav, footer elements
        var nodesToRemove = doc.DocumentNode.SelectNodes(
            "//script|//style|//nav|//footer|//header|//aside|//noscript");

        if (nodesToRemove != null)
        {
            foreach (var node in nodesToRemove)
                node.Remove();
        }

        // Extract text from body
        var body = doc.DocumentNode.SelectSingleNode("//body");
        return body?.InnerText?.Trim() ?? "";
    }
}
            

Content Extraction Features

FeatureHow It WorksUse Case
Title extractionParse <title> and <h1> tagsPage identity, search indexing
Meta descriptionParse meta name="description"Search result snippets
Text extractionRemove scripts/styles, extract body textContent analysis, dedup
Link extractionParse <a href> with URL resolutionDiscovery of new pages
Image extractionParse <img src> with lazy-load handlingImage search indexing
Language detectionAnalyze text content for languageLanguage-specific processing
Structured dataParse JSON-LD, Schema.org markupRich search results
Open Graph tagsParse og:title, og:description, og:imageSocial media previews

12. Distributed Architecture

flowchart TB subgraph Master["Master Node"] M1[URL Frontier Manager] M2[Work Distributor] M3[Status Aggregator] end subgraph Workers["Worker Nodes (1000+)"] W1[Worker 1] W2[Worker 2] W3[Worker N] end subgraph Storage["Distributed Storage"] S1[(URL Frontier DB)] S2[(Content Store)] S3[(DNS Cache)] end M1 --> S1 M2 --> W1 & W2 & W3 W1 & W2 & W3 --> S2 W1 & W2 & W3 --> S3 W1 & W2 & W3 --> M3

Work Distribution Strategy

StrategyHow It WorksProsCons
Random assignmentMaster assigns random URLs to workersSimple, even loadNo domain affinity, poor cache hit
Domain-based shardingWorker owns specific domainsLocal robots.txt cache, rate limitingUneven load if domains differ in size
Work stealingIdle workers steal from busy workersAdaptive to loadComplex coordination
Kafka consumer groupsURLs partitioned in Kafka topicsHigh throughput, automatic rebalancingRequires Kafka infrastructure

Domain-Based Sharding Implementation

C#
public class DomainShardRouter
{
    private readonly int _totalShards;
    private readonly int _shardId;

    public bool IsMyDomain(string domain)
    {
        var hash = HashFunction(domain);
        return (hash % _totalShards) == _shardId;
    }

    // Each worker processes URLs for its assigned domains
    public async Task ProcessUrlsAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            // Fetch URLs only for domains assigned to this shard
            var urls = await _frontier.GetUrlsForMyShardsAsync(
                _shardId, _totalShards, batchSize: 100);

            foreach (var url in urls)
            {
                if (!IsMyDomain(url.Domain))
                {
                    // Forward to correct shard
                    var targetShard = HashFunction(url.Domain) % _totalShards;
                    await _frontier.ForwardToShardAsync(url, targetShard);
                    continue;
                }

                await CrawlUrlAsync(url);
            }
        }
    }
}
            

13. Content Storage & Indexing

Storage Architecture

flowchart TB A[Crawler Workers] --> B[Kafka: crawled-pages] B --> C[Indexer Workers] C --> D[HDFS / S3: Raw HTML] C --> E[Elasticsearch: Text Index] C --> F[PostgreSQL: URL Metadata] D --> G[MapReduce: Content Analysis]

HDFS Storage Layout

Bash
# Daily crawl partition structure
/ crawl-data/
    / 2025/
        / 01/
            / 15/
                / html/
                    / part-00000.gz    # Compressed HTML pages
                    / part-00001.gz
                    / ...
                / metadata/
                    / part-00000.json  # URL metadata (status, hash, links)
                    / ...
                / extracted-text/
                    / part-00000.txt   # Cleaned text content
                    / ...
                / links/
                    / part-00000.csv   # Source URL ? Target URL mappings
                    / ...

# Storage per day (1 billion pages, 500KB each)
# Raw HTML: 500 TB/day ? compressed to ~100 TB/day (5:1 gzip ratio)
# Metadata: 50 TB/day ? compressed to ~5 TB/day
# Total: ~105 TB/day, ~38 PB/year
            

Storage Options Comparison

StorageThroughputCost/TB/monthBest For
HDFSVery high (sequential)$20-40Batch processing, MapReduce
S3 / GCSHigh (object storage)$23Durable archive, CDN origin
CassandraHigh (random access)$100-200URL metadata, recent content
ElasticsearchMedium (indexed)$150-300Full-text search of content
PostgreSQLMedium (ACID)$100-200URL state, job tracking

14. Re-crawling & Freshness

Freshness Strategy

C#
public class RecrawlScheduler
{
    public async Task<TimeSpan> CalculateRecrawlIntervalAsync(UrlEntry url, DomainState domain)
    {
        var baseInterval = url.ChangeFrequency switch
        {
            "always" => TimeSpan.FromMinutes(5),
            "hourly" => TimeSpan.FromHours(1),
            "daily" => TimeSpan.FromDays(1),
            "weekly" => TimeSpan.FromDays(7),
            "monthly" => TimeSpan.FromDays(30),
            _ => TimeSpan.FromDays(7) // Default: weekly
        };

        // Adjust based on observed change rate
        if (url.ChangeHistory != null && url.ChangeHistory.Count >= 3)
        {
            var recentChanges = url.ChangeHistory
                .Where(c => c > DateTime.UtcNow.AddDays(-30))
                .Count();

            if (recentChanges >= 10)
                baseInterval = TimeSpan.FromHours(1); // Changes frequently
            else if (recentChanges == 0)
                baseInterval = baseInterval.Add(TimeSpan.FromDays(7)); // Rarely changes
        }

        // Adjust based on page importance
        if (url.PageRank.HasValue && url.PageRank.Value > 0.8)
            baseInterval = TimeSpan.FromHours(Math.Max(1, baseInterval.TotalHours / 2));

        // Respect domain crawl limits
        if (domain.PagesCrawledToday >= domain.DailyLimit)
            baseInterval = baseInterval.Add(TimeSpan.FromHours(1));

        return baseInterval;
    }

    public async Task ScheduleRecrawlAsync(UrlEntry url)
    {
        var interval = await CalculateRecrawlIntervalAsync(url, url.DomainState);
        url.NextCrawlAt = DateTime.UtcNow.Add(interval);
        await _frontier.UpdateNextCrawlAsync(url);
    }
}
            

Freshness Metrics

MetricDefinitionTarget
Median freshness50% of pages re-crawled within< 24 hours
90th percentile freshness90% of pages re-crawled within< 7 days
Stale page rate% of indexed pages with outdated content< 5%
Change detection rate% of page changes detected within 24h> 90%
Recrawl coverage% of discovered URLs re-crawled monthly> 80%

15. DNS Resolution at Scale

DNS Caching Architecture

C#
public class DnsResolutionService
{
    private readonly IDistributedCache _redisCache;
    private readonly ConcurrentDictionary<string, DnsEntry> _localCache = new();

    public async Task<string> ResolveAsync(string domain)
    {
        // Level 1: Local in-memory cache (per worker)
        if (_localCache.TryGetValue(domain, out var localEntry) &&
            localEntry.ExpiresAt > DateTime.UtcNow)
        {
            return localEntry.IpAddress;
        }

        // Level 2: Redis distributed cache
        var cached = await _redisCache.GetStringAsync($"dns:{domain}");
        if (cached != null)
        {
            var entry = JsonSerializer.Deserialize<DnsEntry>(cached);
            _localCache[domain] = entry; // Populate local cache
            return entry.IpAddress;
        }

        // Level 3: Actual DNS resolution
        var addresses = await Dns.GetHostAddressesAsync(domain);
        var ip = addresses.FirstOrDefault()?.ToString();

        if (ip != null)
        {
            var entry = new DnsEntry
            {
                IpAddress = ip,
                ExpiresAt = DateTime.UtcNow.AddMinutes(5) // Short TTL for local
            };

            _localCache[domain] = entry;

            await _redisCache.SetStringAsync($"dns:{domain}",
                JsonSerializer.Serialize(entry),
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
                });
        }

        return ip;
    }
}
            

DNS Caching Benefits

Cache LevelLatencyHit RateSize
Local in-memory< 0.01ms60%10K entries / worker
Redis distributed< 1ms35%10M entries (20 GB)
DNS query5-50ms5% (misses)Unlimited

16. Error Handling & Resilience

Error Classification

Error TypeHTTP StatusActionRetry?
Not Found404Mark URL as dead, stop crawlingNo
Forbidden403Back off, check robots.txtAfter 1 hour
Too Many Requests429Respect Retry-After, exponential backoffYes, with delay
Server Error500-503Exponential backoff, max 3 retriesYes, with backoff
TimeoutN/AIncrease timeout, retry onceOnce
DNS failureN/ACache failure, retry after 1 hourAfter delay
Connection refusedN/ADomain may be down, retry in 6 hoursAfter long delay
SSL errorN/ALog and skip, may be cert issueNo

Circuit Breaker per Domain

C#
public class DomainCircuitBreaker
{
    private readonly ConcurrentDictionary<string, CircuitState> _circuits = new();

    public async Task<bool> ShouldCrawlAsync(string domain)
    {
        var state = _circuits.GetOrAdd(domain, _ => new CircuitState());

        if (state.Status == CircuitStatus.Open)
        {
            if (DateTime.UtcNow - state.LastFailure > state.RecoveryTimeout)
            {
                state.Status = CircuitStatus.HalfOpen;
                return true; // Allow one test request
            }
            return false; // Circuit open, skip this domain
        }

        return true;
    }

    public void RecordResult(string domain, bool success)
    {
        var state = _circuits.GetOrAdd(domain, _ => new CircuitState());

        if (success)
        {
            state.FailureCount = 0;
            state.Status = CircuitStatus.Closed;
        }
        else
        {
            state.FailureCount++;
            state.LastFailure = DateTime.UtcNow;

            if (state.FailureCount >= 5)
            {
                state.Status = CircuitStatus.Open;
                state.RecoveryTimeout = TimeSpan.FromMinutes(
                    Math.Min(60, state.FailureCount * 5)); // 5-60 min backoff
            }
        }
    }
}
            

17. Monitoring & Observability

Key Metrics Dashboard

PanelMetricVisualizationAlert Threshold
Crawl RatePages crawled per secondTime seriesDrop > 50%
Success Rate2xx responses / total requestsGauge< 90%
Queue DepthURLs pending in frontierTime series> 10B (backlog)
Dedup RateDuplicates / total discoveredGauge< 80% (too many new URLs)
Avg Crawl LatencyTime from URL pick to completionHistogram> 30 seconds
Domain Error RateFailed domains / total domainsTime series> 10%
Bandwidth UsageMB/sec downloadedTime series> 90% of capacity
DNS Cache Hit RateCache hits / total lookupsGauge< 90%
robots.txt ComplianceURLs blocked by robots.txtCounterSpike
Worker HealthActive workers / totalGauge< 80% active

Alerting Rules

AlertConditionSeverityAction
Crawl rate drop< 50% of target for 30 minutesP1Check worker health, network, DNS
High error rate> 20% errors for 15 minutesP1Check for IP block, network issue
Queue overflowFrontier > 10 billion URLsP2Scale workers, check dedup
Worker crash loopWorker restarts > 3 in 10 minutesP2Investigate memory, exception logs
robots.txt violationCrawl attempted on disallowed URLP0Fix robots.txt checker immediately

18. Security & Anti-Abuse

Security Measures

ThreatRiskMitigation
Honeypot trapsCrawler follows hidden links to trap botsRespect nofollow, check CSS visibility
IP blockingTarget servers block crawler IPsRotate IPs, respect rate limits, identify as bot
CAPTCHA/ChallengePages require human verificationStop crawling, don't attempt to solve
Malicious redirectsInfinite redirect loops or malwareLimit redirects (10 max), scan for malware
Content injectionServer detects crawler, injects different contentUse consistent User-Agent, compare content
DDoS via crawlCrawler accidentally overloads a sitePer-domain rate limits, circuit breakers
Data exfiltration riskCrawler accesses sensitive dataOnly crawl public content, respect auth walls

Responsible Crawling Practices

  • Identify yourself: Use a descriptive User-Agent string with contact info
  • Respect robots.txt: Always check and honor the directives
  • Rate limit: No more than 1 request per second per domain
  • Handle errors gracefully: Back off on 429/503, don't retry aggressively
  • Provide opt-out: Honor removal requests promptly
  • Don't circumvent protections: If a site blocks you, respect it
  • Minimize impact: Cache robots.txt, DNS results, and avoid redundant requests

19. Case Studies � Production Crawlers

Google Crawler (Googlebot)

AspectImplementation
Crawl volume100+ billion pages/day
ArchitectureDistributed across multiple data centers
Fetch methodRendering engine (like Chrome) for JavaScript pages
PolitenessAdjusts rate based on server response times
FreshnessImportant pages re-crawled multiple times per day
DedupSimHash for near-duplicate detection

Common Crawl

AspectImplementation
Crawl volume3-5 billion pages per monthly crawl
Storage250-400 TB per crawl (compressed)
FormatWARC (Web ARChive) files
AccessFree, public dataset on AWS S3
ArchitectureApache Nutch-based crawler
UsageAcademic research, ML training, startup prototyping

Other Notable Crawlers

CrawlerScalePurposeKey Feature
Bingbot10B pages/dayBing search indexJavaScript rendering support
Yandex Bot3B pages/dayYandex search indexRussian language optimization
ApplebotUnknownSiri, SpotlightPrivacy-focused crawling
AmazonbotUnknownAlexa, product searchProduct page optimization

20. Cost Estimation

Monthly Cost (1B pages/day)

ComponentSpecificationMonthly Cost
Crawler workers (500)c5.large (2 vCPU, 4GB)$34,560
Fetcher workers (200)c5.xlarge (4 vCPU, 8GB)$27,648
HDFS cluster (50 nodes)d3.xlarge (4 vCPU, 60GB HDD)$43,200
Cassandra (20 nodes)i3.xlarge (4 vCPU, 30GB SSD)$29,760
Redis (10 nodes)r5.large (2 vCPU, 13GB)$5,760
Kafka (6 brokers)k5.xlarge$12,960
PostgreSQLdb.r5.xlarge (4 vCPU, 32GB)$2,700
Bandwidth (100Gbps)Data transfer$15,000
Total~$171,588/month

21. Edge Cases

Edge CaseProblemSolution
Infinite redirect loopPage redirects A?B?A infinitelyLimit redirects to 10, detect cycles
Spider trapCalendar generates infinite pagesLimit pages per domain, detect URL patterns
Massive page (100MB+)Out-of-memory on fetcherStream processing, max page size limit
Binary content (PDF, images)Parser fails on non-HTMLCheck Content-Type, use appropriate parser
Encoding issues (non-UTF8)Garbled text contentDetect encoding, convert to UTF-8
JavaScript-heavy pagesContent not in initial HTMLHeadless browser rendering (Puppeteer)
Dynamic URL parametersSame content with different paramsNormalize URLs, filter tracking params
Authentication required403 on login-only pagesSkip, don't attempt authentication
robots.txt changedPreviously allowed URL now blockedRe-check robots.txt periodically
Content spamLow-quality or auto-generated contentContent quality scoring, demote in index

22. Interview Q&A

Q1: How would you design a web crawler that can crawl 1 billion pages per day?

Start with the architecture: distributed fetcher workers pulling URLs from a shared frontier (Cassandra-backed priority queue). Use domain-based sharding for politeness (one worker per domain shard). Deduplicate URLs with a bloom filter for speed and a hash store for accuracy. Store crawled content in HDFS/S3 for batch processing. Use Kafka as the work distribution bus. Scale to 1 billion/day by adding more workers (each handles ~200K pages/day).

Q2: How do you handle URL deduplication at the scale of 10 billion URLs?

Use a two-level approach: (1) Bloom filter for fast probabilistic check (O(1), 0.1% false positive, 10 bits per URL = 12.5 GB for 10B URLs). (2) For false positives, confirm with exact hash storage in Cassandra/Redis. The bloom filter eliminates 99.9% of duplicates without any database lookup. For content deduplication, use SimHash for near-duplicate detection (finds pages with similar content even if URLs differ).

Q3: How do you ensure politeness while crawling at high speed?

Implement per-domain rate limiting using Redis sorted sets (sliding window algorithm). Maximum 1 request per second per domain, with crawl-delay respected from robots.txt. Use domain-based sharding so each worker owns specific domains and maintains local rate limiting state. Implement exponential backoff on 429/503 responses. Circuit breaker pattern per domain to stop crawling domains that are consistently failing.

Q4: How would you handle JavaScript-heavy pages that don't have content in the initial HTML?

Use a headless browser (Puppeteer/Playwright) for pages that require JavaScript rendering. First, classify pages by whether they need rendering (check for common JS frameworks, AJAX calls). For non-JS pages, use regular HTTP fetching (fast, cheap). For JS-required pages, use headless browser (slow, expensive). This hybrid approach balances crawl speed with content completeness.

Q5: How do you detect and handle spider traps (pages that generate infinite URLs)?

Implement multiple detection mechanisms: (1) Limit total pages per domain (e.g., 10K per day). (2) Detect URL patterns that suggest dynamic generation (calendar pages, session IDs, infinite scrolling). (3) Monitor the ratio of new URLs discovered vs. pages crawled � if a single domain is generating disproportionate URLs, throttle it. (4) Use URL depth limits (max 10 levels from seed).

Q6: How do you prioritize which pages to crawl first?

Use a multi-factor priority score: PageRank (if known), page change frequency (from previous crawls), URL depth (shallower is higher priority), domain authority, freshness requirement (news sites need more frequent crawling), and business value. Implement this as a priority queue in Redis with composite scoring. High-priority URLs are crawled immediately; low-priority URLs may wait hours or days.

23. Conclusion

Designing a web crawler at scale requires mastering several distributed systems challenges: managing billions of URLs in a priority queue, implementing polite crawling with per-domain rate limits, detecting duplicates efficiently across 10+ billion URLs, and handling the diverse landscape of web content (HTML, JavaScript, PDFs, images, redirects, errors).

The key insight for interviews is that a web crawler is fundamentally a work scheduling problem. You have a massive queue of URLs to process, constrained by politeness rules (rate limits, robots.txt), and you need to maximize the quality and freshness of the crawled content. The architecture should be designed around this core constraint: a distributed URL frontier feeding thousands of fetcher workers, with deduplication at the front and content storage at the back.

Remember that web crawlers have real-world impact. Over-crawling can overload servers, violate terms of service, and even lead to legal action. Always design for politeness, identify your crawler clearly, and respect the wishes of website operators. The best crawlers are invisible � they efficiently discover and re-crawl content without causing any disruption to the websites they visit.

© 2025 Ayodhyya Blog Series � All rights reserved.