All posts

Three Things I Learned Building Crawlers with PuppeteerSharp

Headless Chrome from .NET is powerful and fragile in equal measure. Notes from building and maintaining production crawlers over the past few years.

Indeed Job Scrapper Chrome extension detecting and extracting job listings

PuppeteerSharp gives a .NET application full control over headless Chrome — the same engine driving Puppeteer in Node.js, just with a C# API. I've used it to build crawlers that extract structured data from job boards, sports data providers, and content sites that don't expose a usable API. Here are the lessons that actually changed how I write crawlers, not just the obvious "wrap it in try/catch" advice.

1. Treat every selector as temporary

A CSS selector that works today is a selector the target site's next redesign will break. The fix isn't a cleverer selector — it's isolating extraction logic so a broken selector fails loudly and locally instead of silently returning empty data three layers up:

async Task<string?> ExtractTextOrNullAsync(IPage page, string selector)
{
    try
    {
        var el = await page.QuerySelectorAsync(selector);
        if (el is null) return null;
        return (await el.EvaluateFunctionAsync<string>("e => e.textContent"))?.Trim();
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "Selector {Selector} failed", selector);
        return null;
    }
}

Every field returns null on failure instead of throwing, and every null gets logged with the selector that produced it. When a site changes its markup, the crawl still completes — it just tells you exactly which field to go fix.

2. Resource blocking is not optional

By default, a headless Chrome page loads every image, font, and tracking script the target site ships. For a crawler that only needs the DOM and a handful of text fields, that's pure waste — slower runs, more memory, and more chances for a slow third-party script to hang the whole page load.

await page.SetRequestInterceptionAsync(true);
page.Request += async (_, e) =>
{
    if (e.Request.ResourceType is ResourceType.Image or ResourceType.Font or ResourceType.Stylesheet)
        await e.Request.AbortAsync();
    else
        await e.Request.ContinueAsync();
};

This one change cut crawl time on a data-heavy job board by more than half, and made memory usage predictable enough to run several crawler instances concurrently on a single background-worker VM.

3. Respect the site, or lose the ability to crawl it at all

The fastest way to get permanently blocked is to hammer a target with concurrent requests and no delay. A crawler that respects robots.txt, throttles concurrency, and adds jitter between requests looks like a browser with a slow user, not a bot — and stays useful for years instead of getting rate-limited into uselessness after a week.

None of this is exotic. It's the difference between a crawler that works once in a demo and one that keeps working in production, unattended, for months.

Found this useful? Share it.
Mukarram Javid
Written by

Mukarram Javid

Full-stack .NET & Node.js developer, ASP.NET Core, React, Next.js, and PuppeteerSharp web crawling.