Back to blog
c# website scraperc# web scrapinghtmlagilitypack tutorialhttpclient scrapingc# scraping guide

C# Website Scraper How to Build a Fast Reliable Extractor

OutrankSeptember 12, 202616 min read
TL;DR
Learn how to build a C# website scraper with HttpClient, HTML parsing, rate limiting and error handling. Includes code patterns and real-world tips.
C# Website Scraper How to Build a Fast Reliable Extractor

You've written a scraper that works perfectly against a page in your browser. Then the production run starts. Some pages return empty containers because JavaScript hasn't rendered them, others respond with 429 Too Many Requests, and a few stop responding altogether after the site's defenses recognize the traffic pattern. The problem usually isn't extracting a title or price. It's building a C# website scraper that remains useful when the network, markup, and target site all behave unpredictably.

A durable implementation treats scraping as a pipeline: request pages with a reusable client, parse the response, validate extracted fields, retry transient failures, control concurrency, cache completed work, and escalate to browser automation or an API when static HTML is no longer enough. That approach keeps the code understandable while giving you clear points for handling blocking, JavaScript rendering, and maintenance.

Table of Contents

Why C# Is a Strong Choice for Website Scraping

A small team might start with a console application that downloads product pages and selects a few nodes with XPath. The first run looks successful. A later run encounters malformed markup, a temporary server error, or a page whose useful data appears only after a script executes. A scraper built around one synchronous request and a few unchecked selectors has no way to distinguish a missing product from a failed fetch.

C# fits this work because the language and .NET runtime give you strong primitives for long-running network applications. Microsoft first released C# in 2000, then shipped C# 2.0 in 2005, C# 3.0 in 2007, C# 4.0 in 2010, and C# 5.0 in 2012. Those releases introduced capabilities such as generics, lambda expressions, dynamic binding, and async/await, which made network-heavy code easier to organize and maintain. The C# version history provides the underlying timeline.

That evolution matters in a scraper because the difficult work is mostly asynchronous I/O, not CPU-heavy computation. A modern service can reuse HttpClient, await responses without blocking worker threads, deserialize structured results, pass HTML into a parser, and send validated records into a queue or database. Strong types also make schema changes visible during development instead of allowing a renamed field to fail in a loosely structured collection.

A mature ecosystem rewards boring choices

C# scraping commonly centers on a handful of libraries that have survived real project cycles. An industry survey reported 30,509,950 NuGet downloads for HtmlAgilityPack and 20,417,753 for Selenium as of June 2023. It also listed 3,476,719 downloads for AngleSharp and 6,428,430 for CefSharp. These figures are documented in the C# HTML parser library survey.

HtmlAgilityPack has also had active development and support since 2006, giving it a long maintenance history rather than the uncertainty of an experimental package. That doesn't mean download counts prove suitability for every target, but they do explain why production .NET teams often choose established parsers and browser bindings instead of assembling an entirely new stack.

Practical rule: A parser is only one component. Treat fetching, pacing, retries, validation, storage, and observability as part of the scraper from the first design.

C# makes sense when your surrounding application already runs on .NET, needs typed domain models, shares authentication or storage code with existing services, or must run as a hosted worker. Python and low-code platforms can be sensible alternatives when the team needs a quick prototype or a managed extraction workflow. The right comparison isn't language versus language. It's whether your team can maintain the complete extraction pipeline after the target site changes.

If you're still deciding whether your project is really scraping or reading content from a browser interface, this overview of screen scrapers and their role helps clarify the distinction. A screen-oriented workflow often depends on rendered content and interaction. A well-designed C# scraper starts with the cheapest reliable layer, then escalates only when the target requires it.

Choosing Your C# Scraping Stack and Tools

Start with the target, not the library. If the response already contains the fields you need, a browser is unnecessary overhead. If the response is an application shell that fills itself through JavaScript, a parser can't recover data that never arrived in the HTML.

An infographic showing C# scraping tools including HttpClient, HtmlAgilityPack, and Selenium/Playwright with considerations for web developers.

Match the tool to the page

HttpClient is the transport layer, not an HTML parser. Use it for direct requests, JSON endpoints, sitemap files, and static pages. Keep one client for reuse, configure timeouts deliberately, and preserve response status information so later stages can classify failures.

HtmlAgilityPack remains a practical default for static HTML, especially when the markup is inconsistent and XPath is a comfortable querying model. AngleSharp offers a browser-like DOM and CSS selector APIs, making it a good fit when selectors are complex or the HTML follows modern standards. Neither library executes a full browser page, so neither can magically produce content that depends on client-side rendering.

Selenium, Playwright for .NET, and CefSharp belong in the dynamic tier. They can load a page through a browser engine, wait for elements, interact with controls, and observe content after scripts run. The trade-off is operational weight. Browser processes consume more resources, start more slowly, require lifecycle management, and expose a larger surface for failures and detection than a direct HTTP request.

Library Best For Maturity Signal
HttpClient Direct HTML, JSON, XML, and REST requests Core .NET networking primitive
HtmlAgilityPack Static or malformed HTML with XPath extraction Long-lived parser with established NuGet adoption
AngleSharp Standards-oriented DOM work and CSS selectors Mature C# HTML and CSS parsing ecosystem
Selenium Browser interaction and broad WebDriver compatibility Large cross-browser automation ecosystem
Playwright for .NET Modern browser automation and dynamic workflows Microsoft-backed .NET browser automation option
CefSharp Embedded Chromium scenarios and rendered content Established Chromium integration for .NET

The headless Chrome browser guide is useful when your target needs rendering, interaction, or browser-level inspection. Don't introduce that complexity merely because it feels more capable. A direct request plus parser is easier to test, cheaper to run, and usually less fragile.

A decision matrix that holds up

Use a static parser when the required text appears in the initial response, pagination is represented by ordinary links, and the site doesn't require interaction. Move to a browser when content appears only after script execution, a click reveals the records, or login and session flows are part of the permitted workflow.

There's also a middle ground. Use a browser to obtain rendered HTML, then pass that HTML to AngleSharp or HtmlAgilityPack for extraction. This separates rendering from parsing and prevents browser selectors from becoming your entire data model.

My default stack is therefore simple: HttpClient for transport, HtmlAgilityPack or AngleSharp for DOM work, a typed record model for output, and a worker or queue for scheduling. I add Playwright or Selenium only after capturing the raw response proves that direct HTTP cannot provide the required fields.

Building Your First C# Website Scraper From Scratch

The first useful implementation should be small, asynchronous, and explicit about failure. The example below fetches a page, parses product cards, resolves relative links, and returns typed records. Replace the selectors with selectors from your permitted target, and treat them as configuration when multiple page templates exist.

A flowchart infographic titled Building Your First C# Website Scraper From Scratch outlining five essential development steps.

Set up a reusable HTTP layer

Register HttpClient through IHttpClientFactory in an application, or create a long-lived instance for a small console utility. Don't instantiate a new client inside every loop iteration. Add a descriptive user agent, pass a cancellation token, and keep the response status available to the caller.

using System.Net;
using System.Net.Http.Headers;

public sealed class PageFetcher
{
    private readonly HttpClient _client;

    public PageFetcher(HttpClient client)
    {
        _client = client;
        _client.DefaultRequestHeaders.UserAgent.Add(
            new ProductInfoHeaderValue("CatalogCollector", "1.0"));
    }

    public async Task<string> GetHtmlAsync(
        Uri uri,
        CancellationToken cancellationToken = default)
    {
        using var response = await _client.GetAsync(uri, cancellationToken);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync(cancellationToken);
    }
}

EnsureSuccessStatusCode is appropriate for a first checkpoint, but production code should usually classify 429, 5xx, redirects, and permanent client errors before deciding whether to retry. You'll add that policy around the fetcher rather than burying it inside the parser.

Parse into typed records

Install HtmlAgilityPack from NuGet, load the response string, and use selectors that reflect the page structure. Null checks matter because a changed class name shouldn't crash the entire batch or create misleading records with empty fields.

using HtmlAgilityPack;

public sealed record Product(string Name, string Price, Uri Url);

public static List<Product> ParseProducts(string html, Uri pageUri)
{
    var document = new HtmlDocument();
    document.LoadHtml(html);

    var products = new List<Product>();
    var nodes = document.DocumentNode.SelectNodes(
        "//article[contains(@class,'product')]");

    if (nodes is null)
        return products;

    foreach (var node in nodes)
    {
        var nameNode = node.SelectSingleNode(".//h2");
        var priceNode = node.SelectSingleNode(
            ".//*[contains(@class,'price')]");
        var linkNode = node.SelectSingleNode(".//a[@href]");

        if (nameNode is null || priceNode is null || linkNode is null)
            continue;

        var href = linkNode.GetAttributeValue("href", string.Empty);
        if (!Uri.TryCreate(pageUri, href, out var absoluteUrl))
            continue;

        products.Add(new Product(
            WebUtility.HtmlDecode(nameNode.InnerText).Trim(),
            WebUtility.HtmlDecode(priceNode.InnerText).Trim(),
            absoluteUrl));
    }

    return products;
}

XPath is a good fit for HtmlAgilityPack, while AngleSharp would let you express the same extraction with CSS selectors. Use stable attributes where possible. Avoid selectors that depend on a deep chain of anonymous wrapper elements, because small presentation changes will break them.

Separate extraction from persistence

Keep parsing pure. It should accept HTML and return records without writing files, calling a database, or logging into a remote system. That makes selector tests fast and lets you replay saved responses when a target changes.

For a small output file, CsvHelper can serialize the typed list:

using System.Globalization;
using CsvHelper;

public static void WriteCsv(
    IEnumerable<Product> products,
    string path)
{
    using var writer = new StreamWriter(path);
    using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
    csv.WriteRecords(products);
}

A production service will often write to a durable store or publish records to a queue instead. Whichever destination you choose, include the source URL, fetch timestamp, parser version, and extraction status. Those fields help you explain why a record changed and whether the source or your code caused the change.

For a broader explanation of the extraction stage, see this practical guide to extracting data from a web page. The central discipline is the same: fetch once, parse deliberately, validate before storing, and preserve enough context to reproduce a failure.

Making Your Scraper Fast Yet Respectful and Reliable

Concurrency is useful only when the target can tolerate it and your pipeline can absorb failures. A published C# benchmark estimated that 1,000 pages took about 33.3 minutes sequentially, about 3.3 minutes with 10 concurrent requests, and about 20 seconds with 100 concurrent requests, assuming roughly 2 seconds per page. The same benchmark warns that excessive parallelism can trigger blocking, so the figures are a ceiling for a controlled test, not a production target. See the C# concurrent request benchmark for the stated assumptions and results.

Cap concurrency instead of guessing

SemaphoreSlim gives you a clear upper bound on simultaneous work. Pair it with a small randomized delay and release the semaphore in a finally block so exceptions don't permanently consume slots.

var gate = new SemaphoreSlim(10);
var random = new Random();

async Task<T> RunLimitedAsync<T>(
    Uri uri,
    CancellationToken cancellationToken)
{
    await gate.WaitAsync(cancellationToken);

    try
    {
        await Task.Delay(
            TimeSpan.FromMilliseconds(random.Next(250, 900)),
            cancellationToken);

        return await ProcessPageAsync(uri, cancellationToken);
    }
    finally
    {
        gate.Release();
    }
}

Batching with Task.WhenAll can improve throughput while retaining a predictable control point. For a large crawl, a bounded channel or queue is usually cleaner than creating a task for every discovered URL. The queue lets you apply backpressure when parsing or storage falls behind.

Retry only recoverable failures

A retry policy should recognize 429 and selected 5xx responses, wait longer after each failure, and stop after a bounded number of attempts. Random jitter prevents many workers from retrying at exactly the same moment.

static TimeSpan Backoff(int attempt, Random random)
{
    var baseDelay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
    var jitter = TimeSpan.FromMilliseconds(random.Next(100, 700));
    return baseDelay + jitter;
}

The C# scraping reliability pattern recommends combining a persistent HttpClient and parser with retry and backoff, caching, throttling, and block detection. It also describes detecting 429 responses, backing off exponentially, randomizing delays, caching processed pages, and routing work through queues or workers.

Logging should record the URL, status code, elapsed time, retry attempt, response size, and parser result. A sudden rise in empty documents, challenge pages, connection resets, or repeated redirects can indicate blocking even when the server returns a technically successful status.

Respect the target: Check robots.txt, read the site's terms and applicable law, identify your client honestly, and keep request volume within a level the site can handle.

Cache by canonical URL and include a content hash when you need to detect changes. Caching prevents duplicate fetches, lowers pressure on the target, and makes reruns cheaper. Don't cache forever by accident. Choose an expiry based on how often the underlying data changes and how stale your application can tolerate it.

The practical design is adaptive. Start conservatively, observe response health, reduce concurrency when throttling signals appear, and increase it only when the target remains stable. More parallel requests can shorten crawl time, but unbounded parallelism turns a fast scraper into a blocked scraper. Guidance on API rate limits is also relevant when your source is an API rather than an HTML page.

An infographic showing four best practices for building an efficient, respectful, and reliable C# web scraper.

Handling JavaScript Heavy Sites and Anti Bot Defenses

A static parser fails in two very different ways. It can receive HTML that lacks the data because JavaScript would have populated it later, or it can receive a challenge, consent screen, or alternate response because the site classified the request as automated. Adding more XPath expressions fixes neither problem.

A detective examines tangled code with a magnifying glass to detect security threats and web scraping

Inspect the raw response before switching tools. If the browser shows product cards but the response contains only an application shell, you need rendering or an underlying data endpoint. If the response contains a block page, changing from HtmlAgilityPack to AngleSharp won't help. You need to reduce request pressure, use an authorized access path, preserve a valid session where permitted, or reconsider whether direct scraping is the right architecture.

Escalate in controlled steps

Use Playwright or Selenium when the workflow requires a browser. Keep browser contexts short-lived, wait for meaningful selectors instead of arbitrary sleeps, close pages reliably, and capture screenshots or HTML snapshots when extraction fails. A browser should be a deliberate escalation, not the default transport for every URL.

Some targets also require session management, geographic routing, or proxy infrastructure. A residential backconnect proxy can be relevant to legitimate, authorized collection where regional access and session continuity are part of the requirements, but proxies don't remove legal, contractual, or rate-limit obligations.

The market is moving toward higher-level services that combine browser automation, proxy rotation, unblocking, parsing, and retry logic rather than forcing every team to operate those layers independently. Coverage of the 2025–2026 scraping identifies this shift as a response to JavaScript-heavy pages, anti-bot systems, rotating sessions, and distributed collection requirements. That web scraping report frames headless browsers as important while describing the move toward bundled automation APIs.

A hybrid design often works better than a pure browser stack. Let C# own scheduling, validation, domain models, persistence, and monitoring. Let a browser or managed extraction layer handle rendering only for the routes that need it. If the target exposes a stable, permitted API, use that instead of reproducing the browser's network activity. The smaller your custom scraping surface, the fewer selectors and browser behaviors you must repair after each site redesign.

This video offers a visual look at browser-driven extraction and the kinds of interactions that static HTTP clients can't reproduce:

The contrarian decision is sometimes to avoid a custom scraper altogether. Recent coverage describes a move toward low-code tools, API scraping, and hybrid architectures as teams look for lower-maintenance ways to collect data. It also notes that Selenium and Playwright remain common while the broader tool ecosystem stays fragmented, which suggests that integration and reliability are often harder than writing the first parser. The web scraping trends analysis supports that architecture-first view.

Putting It All Together and Next Steps for Production

A production C# website scraper should have a short, testable path from URL to stored record:

  1. Fetch: Reuse HttpClient, set timeouts, preserve status codes, and pass cancellation tokens.
  2. Classify: Separate successful pages, throttling responses, server failures, redirects, and block pages.
  3. Parse: Use HtmlAgilityPack or AngleSharp for static HTML, with null-safe selectors and typed records.
  4. Escalate: Route JavaScript-dependent pages to Playwright, Selenium, an API, or a managed extraction layer.
  5. Control: Apply bounded concurrency, backoff, jitter, caching, and queue-based work distribution.
  6. Verify: Track extraction completeness, schema changes, response health, and duplicate records.
  7. Store: Preserve source context, fetch time, parser version, and processing status with each result.

Schedule the worker through your hosting environment, add structured logs, and alert on changes in response status or field completeness rather than waiting for users to report missing data. Respect robots.txt, terms, access controls, privacy requirements, and applicable law before collecting or redistributing information.

For teams collecting public social data, Captapi can serve as the structured API layer instead of making your service maintain page selectors for every platform. It exposes a REST interface for public social data such as transcripts, comments, summaries, engagement metrics, profiles, page details, downloads, and search results, allowing a C# orchestrator to consume structured output rather than raw HTML.

The wider architecture is worth studying too. This case study on how built an AI platform shows why adaptive collection, downstream processing, and reliability decisions belong in the same system design conversation. Your scraper is not finished when it extracts one page. It's finished when the pipeline can detect change, recover from transient failure, and give you a clear reason when it can't collect a record.

For repeatable operations, connect the scraper to data pipeline automation, then review selectors and source behavior as part of normal maintenance rather than emergency repair. If a permitted API or structured provider can deliver the same data with less custom infrastructure, choose the thin C# orchestrator. That's often the most maintainable form of scraping.


Captapi provides a REST API for structured public social data, including transcripts, comments, summaries, engagement metrics, profiles, page details, downloads, and search results, so your C# service can consume records without maintaining every browser and parser workflow. Visit Captapi, create an API key, and test the endpoint that matches your pipeline before committing to a custom scraper stack.