Category: Uncategorized

  • Handling pagination that changes shape

    The problem shows up as a silent data gap, not an error

    Most scraping failures are loud. A 403, a CAPTCHA wall, a timeout. Pagination failures are quieter. The scraper keeps running, keeps returning 200s, and keeps writing rows to your database. It just stops writing the rows that matter, because it fell off the end of a pagination scheme it assumed would hold steady. You find out three weeks later when someone asks why a category page that should have 40,000 listings only has 1,200.

    This happens because pagination logic gets written once, against whatever the site is doing on the day you build the scraper, and then treated as a fixed contract. It isn’t one. Pagination is a UI and infrastructure decision made by product and backend teams for reasons that have nothing to do with you, and it changes more often than scrapers assume.

    The shapes pagination actually takes

    There are really four families, and most sites use more than one depending on where you are in the site.

    Offset and page number. ?page=3 or ?offset=60&limit=20. This is the easiest to reason about and the easiest to break, because it assumes a stable, ordered list underneath. If the site re-sorts results between requests (common on anything with “sort by relevance” or live inventory), offset pagination silently skips or duplicates items even without any format change.

    Cursor-based pagination. Instead of a number, the response includes an opaque token, something like next_cursor: "eyJpZCI6NDIw...", that you pass back on the next request. This is what most modern APIs use, including GraphQL’s Relay-style edges / node / pageInfo.endCursor pattern. It’s more resistant to re-sorting but it means your scraper can’t jump to page 40 without walking through 39 first, and it can’t be parallelized the same way offset pagination can.

    Infinite scroll / “load more”. No URL parameter at all from the user’s point of view. The page fires an XHR or fetch call on scroll or button click, usually hitting an internal API that mirrors one of the two patterns above. The catch is that this internal API is often undocumented, versioned separately from the public one, and changed without any changelog.

    Hybrid and inconsistent. The one that actually causes the most incidents. Category pages use offset, search results use cursor, and the mobile web view uses infinite scroll hitting a third endpoint entirely, all on the same domain. A scraper built against one page type and pointed at another silently mispaginates.

    Why sites change shape mid-flight

    It’s worth being clear-eyed about this: most pagination changes are not anti-scraping measures. They’re normal engineering. A team migrates a REST endpoint to GraphQL. A backend swaps offset pagination for cursor pagination because deep offsets get slow on large tables (this is a genuine, well-documented Postgres and MySQL performance problem, not a scraper deterrent). A frontend redesign replaces numbered pages with infinite scroll because it tests better on mobile. An A/B test rolls out to 10% of traffic and your scraper happens to land in the test bucket on one run and the control bucket on the next.

    Some of it is deliberate friction. Sites that are aware of heavy automated traffic do sometimes make pagination less stable on purpose, rotating parameter names or adding required session state between page requests, specifically because static scrapers assume URL patterns hold. But you generally can’t tell which cause you’re looking at just from the symptom, and treating every change as hostile leads to fragile, paranoid code. Treat it as what it almost always is: the site changed and your assumption didn’t update with it.

    Detect the shape instead of assuming it

    The fix starts before you write a single line of extraction logic. Open the network tab, not the rendered HTML, and find the actual request that returns the list data. Look at three things:

    1. Does the request include a page number, an offset, or a cursor token in the request itself?
    2. Does the response include a total count, a next-page URL, a next-cursor field, or none of those?
    3. Is the endpoint versioned (/api/v2/listings) or does it look like an internal, unversioned call that could change without notice?

    If the response has an explicit has_next_page: false or an empty next_cursor, your stopping condition is right there in the data and you should use it instead of guessing. If it doesn’t, you’re dealing with a site that expects the frontend, not an API consumer, to decide when to stop, which is a signal that the format is more likely to shift under you later.

    Building a scraper that survives a format change

    The practical fix is to stop hardcoding a single pagination strategy and instead write an extraction step that checks its own assumptions on every run, not just at build time.

    Concretely, that means:

    • Parse the pagination signal from the response itself (cursor field, next-page link, total count) rather than incrementing a counter you set once in config.
    • Add a schema check at the top of your parsing function. If the expected next_cursor key is missing or a new key like nextPageToken appears in its place, throw a specific, loud error instead of falling through to a default that silently stops paginating.
    • Log the item count returned per page alongside the page identifier. A page that suddenly returns 0 items when every prior page returned 20 is a stopping condition, but it’s also worth flagging separately from “we hit the end,” because it could mean the endpoint started rejecting your requests in a way that still returns 200.
    • Cross-check total item count against what the site’s own UI reports, when it reports one at all. A scraper that pulls 1,200 of a stated 40,000 listings and exits cleanly is the exact failure mode that goes unnoticed the longest.

    None of this requires exotic tooling. It’s mostly about not trusting your own code’s silence. A pagination loop that can exit without an explicit, logged reason is the thing to eliminate.

    Stopping conditions that don’t lie to you

    A surprising number of pagination bugs come down to a stopping condition that’s technically correct and practically wrong. “Stop when the page returns fewer than the requested limit” is a reasonable rule until a site starts padding short pages with recommended or sponsored items to keep the count consistent, at which point your scraper never stops and you’re re-requesting the same tail page in a loop.

    “Stop after N pages” is worse, because it has no relationship to the actual data and will quietly under-collect the moment the site’s result set grows.

    The stopping conditions that hold up are the ones grounded in something the site itself asserts: an explicit has_more: false, a next_cursor that comes back null, or a total count you can check your running total against. When none of those exist, the more honest approach is to paginate until you see a repeat of an item you’ve already collected (by ID, not by position), which tells you the underlying list looped or reset, and to log that event rather than treat it as a normal end state.

    What this means for pipeline design

    If pagination logic lives buried inside a single scraper script, a format change means a silent partial run every time until someone notices the row count looks wrong. The more durable pattern is to treat pagination parsing as its own small, tested unit, separate from request handling and separate from data transformation, so a change in the site’s response shape throws a clear, specific failure in one place instead of a vague downstream data-quality problem three steps later. Combined with per-run item count logging and count checks against any total the site publishes, this turns “pagination changed and we didn’t know” from a recurring incident into a one-line alert.

    Pagination that changes shape is not a special case. On any site actively maintained by a real engineering team, it’s the default state over a long enough timeline. Building for that from the start costs a little more up front and saves you from finding gaps in your data months after they opened.

    If you want more of this kind of infrastructure-level breakdown of how scraping pipelines actually hold up in production, you can find the rest of our write-ups on the Data Research Tools home page.

    Get new guides and videos first — join the Telegram channel.

  • Residential proxy pricing models compared

    Why residential proxy pricing is hard to compare

    Pull up five residential proxy providers and you’ll get five different pricing structures that don’t map onto each other cleanly. One charges per gigabyte. Another charges per IP. A third bundles everything into a flat monthly fee with a soft usage cap. None of these numbers tell you what you actually want to know, which is: what does it cost to run my scraper or my automation job to completion.

    We run proxy infrastructure ourselves, across residential, mobile, and datacenter pools, and we’ve built pipelines that burn through bandwidth on other people’s networks as a line item. The pricing model a provider picks isn’t cosmetic. It changes how much a scraping job costs you depending on what you’re actually doing: high page-weight sites versus lightweight API calls, long sessions versus one-shot requests, broad geographic coverage versus a single country. Here’s how the four common models actually work, and where each one quietly costs you more than the headline number suggests.

    Pay-per-GB: the default for a reason

    Bandwidth-metered pricing is the most common model in residential proxying, and it’s the most common for a structural reason: the provider itself is usually paying for that traffic, either through an SDK partnership with app developers who bundle a proxy client into free apps, or through a peer network where residential devices relay traffic in exchange for something. Either way, the provider’s own cost scales with bytes moved, so their pricing does too.

    This model rewards lightweight traffic. Scraping a JSON API endpoint that returns 20KB per call is cheap under pay-per-GB. Scraping a modern e-commerce page with lazy-loaded images, video previews, and three ad networks firing in the background is not, because a full-page load can run into multiple megabytes once you count every asset the browser fetches. If your scraper renders pages in a headless browser instead of hitting an API directly, your GB consumption per successful data point can be five to ten times higher than a request-only approach, and that difference lands directly on your bill.

    The practical move here is blocking unnecessary resource types (images, fonts, video, most third-party scripts) at the browser level before they hit the proxy, since every blocked request is a request you never pay bandwidth for. This is a request-shaping decision on your side, not a proxy configuration trick, and it’s the single biggest lever on pay-per-GB cost.

    Flat-rate / unlimited: the cap is never really unlimited

    Unlimited residential plans exist because it’s a strong marketing word, not because the underlying network has infinite capacity. Somewhere in the terms of service or a support conversation there’s a fair-use ceiling, a thread/concurrency limit, or a throttle that kicks in once you’re moving enough traffic to matter. The provider is still paying per byte on their end; they’re just absorbing that variance across a large customer base and hoping your usage sits near the average.

    The way to evaluate a flat-rate plan is to ask directly what the concurrency and thread caps are, since that’s the real constraint, not the word “unlimited” on the pricing page. A plan capped at 50 concurrent connections behaves completely differently from one capped at 500 once you’re running a distributed scrape across thousands of targets, even if both are billed the same flat monthly rate. If a provider won’t state a concurrency number in writing, treat that as a gap in the pricing model, not a feature.

    Flat-rate plans make the most sense when your traffic volume is predictable and moderate. They make less sense for spiky workloads, like a monthly full-catalog re-scrape that needs to move a huge volume of data in a short window, because that’s exactly the pattern fair-use throttling is designed to catch.

    Per-IP pricing: paying for the pool, not the traffic

    Some providers, especially on the datacenter and static-residential (ISP) side, charge per IP address rather than per byte. You pay a flat fee per IP per month and then move as much traffic through it as the connection allows. This model shifts the cost driver from bandwidth to pool size and IP diversity.

    Per-IP pricing makes sense when your bottleneck is distinct identities rather than raw throughput, for instance when you need many different source IPs to distribute request volume across a target site’s per-IP rate limits, rather than needing a lot of total bandwidth through any single one. It tends to cost more per byte moved than pay-per-GB residential if you’re pushing heavy traffic through a small number of IPs, but it’s more predictable, since your bill doesn’t move with a bad day of page bloat.

    The trap with per-IP pricing is buying more IPs than your target actually requires. Rate limiting on the target side is usually based on request frequency and behavioral signals, not purely on IP count, so doubling your IP pool without changing request pacing or session handling often just doubles your bill without improving success rate.

    Tiered subscriptions: bundled convenience, opaque unit economics

    The fourth common model bundles a GB allowance, a session or IP limit, and sometimes geo-targeting options into named tiers, similar to how a SaaS product prices seats. This is the easiest model to buy from and the hardest to evaluate, because the effective price per GB usually drops as you move up tiers, which pushes buyers toward committing to more volume than they need in order to hit a better unit rate.

    The way to compare tiers honestly is to divide the tier price by its stated GB allowance and treat overage pricing as the real marginal cost, since that’s what you pay once you’re not perfectly sized to a tier. Overage rates on subscription plans are frequently worse per-GB than the base tier rate, sometimes worse than a plain pay-per-GB competitor’s list price, because overage is priced as a convenience charge rather than a competitive rate.

    What none of these models price in

    Pricing pages describe cost per byte or per IP. They don’t describe cost per successful request, which is the number that actually matters for a production scraping pipeline. A cheap plan that gets blocked, fingerprinted, or rate-limited on your target site and forces retries burns bandwidth on failed attempts you still pay for. A more expensive plan with better session handling, cleaner IP reputation, and lower detection rates on the sites you actually care about can end up cheaper per successful data point, even at a higher headline price.

    This is where bot-detection systems on the target side become part of the cost equation, even though no proxy pricing page mentions it. Sites running fingerprinting and behavioral analysis (checking TLS handshake characteristics, header ordering, mouse movement patterns, and IP reputation history) will flag and block traffic regardless of which pricing tier moved it. We don’t publish techniques for defeating those systems here, because that’s not a responsible use of this space and it wouldn’t hold up as reliable advice anyway, defenses change constantly. What’s worth understanding is that IP reputation and network diversity, the things residential and mobile proxy pools actually sell, are part of why they exist as a category at all: legitimate residential IP space carries less baseline suspicion than datacenter ranges on sites that check for it, which is a structural fact about how these networks are built, not a guarantee about any specific outcome.

    No provider or technique here is being called undetectable or risk-free, and none should be. Pricing model aside, every proxy network has a failure rate against production bot-detection systems, and that failure rate should factor into your real cost calculation just as much as the dollar figure per GB.

    Choosing a model for your actual workload

    Match the model to your traffic shape, not to whichever plan looks cheapest on the pricing page. Lightweight, API-style scraping favors pay-per-GB. Predictable, moderate-volume automation favors flat-rate with a known concurrency cap. Distributing requests across many identities to respect per-IP rate limits favors per-IP pricing. High-volume operations that can commit to a stable baseline favor tiered subscriptions, as long as you price the overage rate honestly.

    Whatever model you land on, calculate cost per successful request before committing, not cost per GB or per IP in isolation. That’s the number that tells you what the pipeline actually costs to run.

    For more breakdowns like this on scraping infrastructure, proxy pools, and the detection systems they run up against, head back to Data Research Tools.

    Get new guides and videos first — join the Telegram channel.

  • Building a test suite for your scrapers

    Most scraping teams find out something broke when a downstream report looks wrong, not when the scraper actually fails. The job ran, it exited zero, and it wrote rows to the database. The rows were just empty, or truncated, or pulled from the wrong element. That gap between “the script ran” and “the script worked” is the whole argument for scraper testing, and it’s different from testing normal software because the thing you depend on (someone else’s website) changes without telling you.

    I run production scraping pipelines against proxy pools we operate ourselves, and the failures that actually cost time are almost never the exciting ones. They’re a class name that changed, a JSON key that got renamed, a redirect that now goes somewhere new. A test suite doesn’t stop those changes from happening. It stops them from happening quietly.

    What actually breaks in a scraper

    Before writing tests, it helps to be specific about failure modes, because “test the scraper” is too vague to act on.

    Selector and markup drift. A site redesigns a card layout, renames a class, or nests a price inside an extra div. Your CSS or XPath selector still runs without error, it just returns nothing, or returns the wrong node. This is the single most common cause of silent data loss.

    Schema drift on structured endpoints. If you’re pulling from a JSON API or an embedded <script type="application/ld+json"> block, the same problem shows up as a renamed or removed key. Your code does data["price"] and gets a KeyError, or worse, data.get("price") and gets None that flows silently into your database.

    Network and proxy failures. Timeouts, connection resets, a proxy that’s gone stale, a datacenter IP that’s getting blocked while your residential pool is fine. These look like infrastructure problems, but from the scraper’s point of view they’re just another way to get an incomplete response.

    Rate limiting and blocking responses. A 429, a 403, or a 200 with a challenge page instead of content. If your scraper treats any 200 as success, you’ll write garbage to your database and never know it happened.

    Data quality drift. The page loads, the selector matches, but the value is wrong shape: a price field that’s suddenly a string with a currency symbol instead of a number, a date in a new format, a field that used to always be present now sometimes empty.

    Each of these needs a different kind of test. Treating “scraper testing” as one activity is why most teams either skip it or build something that only catches the easy cases.

    Unit tests: pin your parsers to fixtures

    The parsing logic, the part that takes raw HTML or JSON and turns it into structured fields, is the easiest and highest-value part to test, because it doesn’t need the network at all.

    Save real HTML responses as fixture files in your repo, one per page template you scrape. Write tests that load the fixture, run it through your parser, and assert on the exact fields you expect: field names, types, and a couple of known values from that snapshot. When the site changes its markup, you update the fixture from a fresh page pull and the test tells you exactly which field broke, without touching a live server or burning a proxy request.

    This does two things well. It catches parser bugs before deploy, and it gives you a fast regression check when you refactor extraction code. What it can’t do is tell you the site has already changed in production, because the fixture is frozen at the moment you saved it. That’s what the next layer is for.

    Integration tests: hit the real page on a schedule

    Integration tests run your scraper against the live target on a schedule, separate from your main crawl, and check that the shape of the output still matches expectations. This is where you catch drift as it happens instead of finding it in a downstream report three weeks later.

    Keep the assertions structural, not content-specific. Don’t assert that a price equals a fixed number, prices change. Assert that a price field exists, parses as a number, and falls inside a sane range. Assert that a title field is non-empty and under some reasonable length. Assert that the number of items returned from a listing page is above zero and below some upper bound that would indicate you’re parsing the wrong container.

    Run these on a small, fixed set of URLs, not your full crawl target list. The goal is early warning, not coverage. A handful of canary pages per site template, checked every few hours, will surface a broken selector long before it shows up as a gap in your dataset.

    Contract tests for your own output

    A scraper’s real interface isn’t the website, it’s whatever consumes the data next, a pipeline, a database table, an API you expose. Contract tests check that your output still matches what that consumer expects, independent of what the source site is doing.

    This catches a different class of bug: your extraction logic changed for one reason and quietly altered the output schema for another. You added a field, and something downstream that does strict key matching now breaks. You changed a date parser and the format shifted from ISO 8601 to something a downstream job can’t parse. These tests run against your own code’s output, so they’re fast, deterministic, and don’t depend on the network being up.

    Testing the network layer without testing the target site

    The network and proxy layer needs its own tests, and they should not depend on the target site being reachable, because you want to know your retry logic and error handling work correctly regardless of what the site is doing that day.

    Test against a local mock server or a small controlled test endpoint you own: does your code retry on a timeout, back off correctly, and eventually give up instead of looping forever? Does it correctly distinguish a 429 from a 200, and does a 403 get logged and flagged instead of silently treated as an empty result? Does your proxy rotation actually rotate, and does a dead proxy get pulled out of the pool instead of retried indefinitely?

    This layer is where most of the operational pain lives day to day, and it’s the one teams test least, because it feels like infrastructure rather than “the scraper.” In practice, a scraper that handles proxy failures and blocking responses correctly, even with mediocre parsing, degrades gracefully. One that handles them badly can turn a temporary block into hours of wasted requests and silently corrupted data.

    Monitoring is a test suite that never stops

    Once a scraper is in production, the highest-value test isn’t in your test runner at all, it’s monitoring on the output. Track fill rate (the percentage of expected fields that came back non-empty) per page template, per day. Track response status code distribution across your requests. Track the count of items scraped per run against a rolling average, and alert when it drops sharply.

    These are structurally the same assertions as your integration tests, just running continuously against real traffic instead of on a schedule against canary pages. The combination matters: integration tests catch drift fast on a small sample, monitoring catches drift at scale across your full crawl, including drift that only shows up on some page variants and not others.

    Building this incrementally

    You don’t need all four layers before you ship anything. If you’re starting from nothing, the order that pays off fastest is: fixture-based unit tests for your parsers first, because they’re cheap and catch the most common bug; fill-rate monitoring on your actual pipeline output second, because it turns “the data looks off” into a specific alert instead of a vague feeling; then integration tests against canary URLs; then contract tests once you have more than one consumer of the data to protect.

    None of this makes a scraper immune to breaking. Sites change, proxies go stale, and a redesign can still catch you off guard the first day it ships. What a real test suite buys you is speed of detection, finding out in an hour instead of a month, and precision, knowing which field or which page template broke instead of guessing across a whole pipeline. That’s the difference between scraping as a one-off script and scraping as infrastructure you can actually run.

    If you’re building out scraping infrastructure, proxy pools, or pipeline tooling and want more of this kind of operator-level detail, check out the rest of what we cover at Data Research Tools.

    Get new guides and videos first — join the Telegram channel.

  • Deciding how often a page really needs rechecking

    The default interval is almost always wrong

    Most scraping setups start with one number. Every hour, every six hours, every day, applied to every target in the list regardless of what that page actually is. It’s the easiest thing to configure and the first thing that gets abandoned once the pipeline is running in production, because nobody revisits it until something breaks.

    The problem is that “how often does this page change” is not one answer. A product listing page, a company’s about page, and a job board posting all update on completely different clocks, and treating them the same wastes either compute or freshness, usually both at once.

    What you’re actually paying for on every check

    Every recheck costs something even when nothing changed. A proxy request, a render if the page needs JavaScript, storage for the response, and a diff pass to confirm there was no update. None of that is free, and at scale it adds up faster than people expect.

    Run the math on a target list of a few thousand URLs checked hourly. That’s tens of thousands of requests a day before you’ve extracted a single new fact. If 90% of those checks return an unchanged page, which is common for slow-moving content, you’re burning proxy bandwidth and render time to confirm nothing happened. It’s the majority of your operating cost on a target that didn’t need it.

    On the other side, checking too infrequently means you miss the window entirely. A flash sale that runs for six hours, a stock alert that clears in minutes, a job posting pulled after the role fills. If your interval is longer than the thing you’re tracking is even live, the frequency number is decorative. You paid for infrastructure that structurally cannot catch the event.

    Group targets by how they actually behave, not by convenience

    The fix isn’t a smarter algorithm, it’s admitting that your target list is not one population. Split it by observed change behavior and give each group its own interval.

    Near-static pages. Company about pages, terms of service, static documentation, archived listings. These change on the order of weeks or months. Checking them hourly produces months of identical diffs. A weekly or even monthly check catches essentially everything that matters, and the savings on request volume are large because this is usually the biggest bucket by page count.

    Slow-moving content. Product catalog pages without live inventory counters, blog indexes, category pages. These shift daily or every few days as new items get added or descriptions get edited. A check every 6 to 24 hours is usually enough, and you can tighten that if you notice a specific source publishing on a predictable schedule.

    Fast-moving content. Pricing pages, inventory counts, job boards, auction listings, anything with a countdown or a limited quantity. These need checks measured in minutes, not hours, and even then you’re accepting that you’ll sometimes land between two states of a fast-changing value.

    The grouping doesn’t have to be manual forever. Once you’ve run a target for a few weeks, you have real diff history. Use it.

    Let observed change rate set the interval, not a guess

    The most reliable signal you have is the page’s own history. If you’ve been storing diffs (and you should be, even a simple hash of the extracted fields is enough), you can calculate the actual interval between changes for each URL instead of assuming one.

    A basic version of this: track the timestamp of the last N changes for a page, take the median gap between them, and set your check interval to some fraction of that, say a quarter to a half. A page that changes roughly every 3 days doesn’t need hourly checks, but it does need more than a weekly one if you want to catch the change within a day of it happening. A page that hasn’t changed in 60 days probably doesn’t need daily checks anymore even if it started in your “daily” bucket.

    This is adaptive scheduling, and it doesn’t require anything exotic. A cron-based scheduler with a per-target interval column in your database gets you most of the way there. The part people skip is updating that column based on observed behavior instead of setting it once at ingestion and forgetting it.

    Respect what the source is telling you

    Some pages are explicit about their own update cadence, and it’s worth reading before you set an interval. RSS and Atom feeds sometimes expose a ttl or updatePeriod field. Sitemaps can include a lastmod timestamp per URL that, if the site maintains it honestly, tells you exactly when a page last changed without you having to poll it at all. robots.txt occasionally sets a Crawl-delay directive, which isn’t a change-frequency signal but is a direct statement from the site operator about how much load they’re willing to absorb from automated requests, and ignoring it is a fast way to get an IP range flagged.

    Checking a sitemap’s lastmod values on a schedule is often cheaper than checking the pages themselves, because it’s one lightweight request that can tell you which of a thousand URLs actually need a full recheck this cycle. Not every site keeps this field accurate, some just stamp it with the current date on every deploy regardless of content changes, so treat it as a hint to prioritize, not a guarantee.

    Where request timing intersects with detection

    This isn’t just a cost question. Request timing is one of the signals that bot detection systems look at, alongside things like TLS fingerprint consistency, header ordering, and browser automation artifacts. A target hit at exactly the same second every hour, from the same IP or IP range, forever, produces a pattern that’s trivial to flag on the server side regardless of how good your headers look. Detection systems don’t need to catch a single request looking wrong, they just need a request pattern that a real user’s browsing behavior wouldn’t produce.

    Adding jitter to your interval, checking every “roughly 60 minutes” with random variance of a few minutes in either direction, is a defensive move against exactly that kind of pattern-based fingerprinting, not a workaround for anything on the target site. It’s the same logic as varying your proxy pool: consistency in timing is itself a signal, and reducing it reduces one input into a detection system’s decision, nothing more. It doesn’t make a request undetectable and it isn’t a substitute for respecting a site’s published crawl policies or terms of service.

    A rough starting framework

    If you’re setting this up from scratch, here’s a reasonable place to start before you have your own diff history to lean on:

    • Static/legal/about pages. weekly checks
    • Catalog, blog, listing pages without live counters. every 6 to 12 hours
    • Pricing, inventory, time-limited listings. every 15 to 60 minutes, with jitter
    • Anything with a sitemap lastmod field. check the sitemap first, only recheck the page if the timestamp moved

    Revisit these numbers after your first month of real diff data. The point of the exercise isn’t to land on the perfect interval on day one, it’s to stop treating every URL like it belongs to the same clock.

    If you’re building out the orchestration side of this, from scheduling to proxy rotation to the fingerprinting considerations that come with running requests at scale, that’s the kind of infrastructure we write about and review at Data Research Tools.

    Get new guides and videos first — join the Telegram channel.

  • Geo targeted scraping and locale headers: why location has to be consistent, not just spoofed

    Why geo targeting is a data problem before it’s a proxy problem

    Most teams treat “scrape this site from Germany” as a proxy selection problem: pick a German IP, done. That’s the easy 20%. The other 80% is making every other signal the request carries agree with that IP, because most sites that localize content don’t localize it off IP alone. They combine IP geolocation with request headers, and increasingly with client-side signals like timezone and language settings read out of the browser. If those disagree, you don’t get blocked outright most of the time. You get the wrong content, silently, which is worse for a data pipeline because nothing errors and nobody notices until the numbers look off.

    We run proxy infrastructure and scraping pipelines that pull region-specific pricing, listings, and search results across dozens of locales a day. The failure mode we see most often isn’t detection. It’s a scraper that thinks it’s collecting French results because the proxy exit node is in Paris, while the actual response came back in English with US pricing because the Accept-Language header still said en-US and the site’s edge logic weighted that over the IP.

    What “geo targeted scraping” actually means at the request level

    A request carries location information in at least four places, and a site can use any combination of them:

    • The IP address itself, resolved through a GeoIP database (MaxMind and similar) to a country, region, and sometimes city.
    • The Accept-Language header, which states the client’s preferred languages and locale variants, e.g. de-DE,de;q=0.9,en;q=0.8.
    • Timezone, exposed to client-side JavaScript via Intl.DateTimeFormat().resolvedOptions().timeZone or Date.getTimezoneOffset(), and sometimes inferred server-side from other signals.
    • Account or session state, where a logged-in user’s stored address or previously-set locale cookie overrides everything else.

    A scraper built to hit “the German version of a page” has to control the first two at minimum, and often the third if the target renders anything client-side or runs an anti-automation script that checks for consistency between IP geolocation and browser timezone as a basic signal. Getting the IP right and forgetting the header is the single most common reason geo-targeted scrapes silently return the wrong locale.

    The locale header stack, briefly

    Accept-Language is the one people remember. The ones people forget:

    • Accept-Encoding doesn’t carry location, but a header set that doesn’t match the claimed client (e.g. a header order or capability set inconsistent with a real browser from that region) is one of many small fingerprint mismatches that detection systems fold into a broader signal, discussed more below.
    • Accept-Charset and content negotiation headers occasionally matter for older or region-specific sites still serving legacy encodings.
    • The Host and any CDN-specific geo headers (Cloudflare, Akamai, and others inject headers like CF-IPCountry on the origin side, not visible to the client, but they show the origin is often making its localization decision independent of what the client sends). This matters operationally: you can set every client header perfectly and still get localized by IP alone, because the origin server, not the browser, made the call.

    The practical implication is that you can’t treat “set Accept-Language to de-DE” as sufficient. You have to know, for each target site, which of these signals it actually reads, and that takes observation, not assumption. We figure this out by running the same request through multiple locale configurations and diffing the responses, not by reading the target’s documentation, because most sites don’t publish how their localization logic works.

    Where IP geolocation and headers have to agree

    Consistency matters more than any single value being “correct.” A residential IP that GeoIP resolves to Frankfurt, paired with an Accept-Language of ja-JP, paired with a browser timezone of America/Los_Angeles, is a set of signals that don’t belong to the same real user anywhere. Individually each one is a valid value. Together they’re a tell.

    This is why geo-targeted scraping infrastructure has to treat location as one coherent identity, not three unrelated settings picked independently. In practice that means:

    • Picking the proxy exit location first, since it’s usually the hardest constraint (you need a real IP in that geography, not a claimed one).
    • Deriving the language header from the country the IP is actually in, not from whatever locale the target market uses on its main site. A US-based researcher targeting the Spanish market needs es-ES, but a proxy exit in Madrid with an Accept-Language of en-US is still a mismatch worth avoiding.
    • Setting timezone to match, if the scraping stack renders JavaScript (headless browser) rather than making raw HTTP requests. Raw HTTP scraping never exposes a timezone at all, which is one reason plenty of geo-targeted pipelines skip the browser layer entirely when the target doesn’t require it.

    Datacenter, residential, and mobile exits behave differently for this

    The proxy type changes how much the IP alone tells the target about you, independent of headers:

    • Datacenter IPs resolve cleanly in GeoIP databases to a specific hosting facility location, which is often not where any real residential population sits, and some sites treat datacenter ASN ranges as a separate signal from geography entirely.
    • Residential IPs resolve to real ISP allocations tied to actual population centers, which is why they’re the default choice when the target’s localization logic is IP-primary and city-level accuracy matters (a listing search scoped to “within 20km of Lyon” needs an IP that GeoIP actually places near Lyon, not just in France).
    • Mobile IPs carry carrier-level geolocation that’s often coarser than residential (a mobile IP can resolve to a carrier’s regional gateway rather than the user’s actual city), which matters if your target’s geo logic is granular below the country or region level.

    None of these are “better” in the abstract. The right exit type depends on how granular the target’s localization is and whether the target scrutinizes ASN and connection type as part of its own signal set, which is genuinely site-specific and changes without notice.

    How detection systems use locale consistency

    This is worth explaining plainly because it gets misrepresented a lot: sites don’t typically block a request because the language header says French. They fold locale consistency into a broader fingerprint alongside TLS handshake characteristics, header ordering, JavaScript environment properties, and request timing. A single mismatched signal rarely triggers anything on its own. A cluster of mismatches, especially ones a casual scraper setup produces reliably (correct IP, default browser locale, no timezone override), is what pushes a session’s risk score up. Defensive systems are built exactly to look for that gap between “claims to be a normal user from this region” and “the accumulated signal set says otherwise,” which is why treating geo targeting as a proxy-only concern under-delivers even when it doesn’t get flagged: the data itself can be wrong before detection ever becomes the question.

    What this means for building a geo-targeted pipeline

    Practically, a scraping pipeline that needs to be geo-aware has to decide, per target, which signals that target actually uses, and that means testing, not assuming. Concretely:

    • Confirm what changes when you vary IP alone with headers held constant, and separately what changes when you vary headers with IP held constant. If the response doesn’t change with the header, the target isn’t reading it, and you can drop that complexity for that specific site.
    • Keep IP, language header, and (if using a browser) timezone derived from a single “locale profile” per target country rather than set independently, so they can’t drift out of sync as the pipeline scales to more regions.
    • Log which locale profile produced which response, so a wrong-locale result is traceable to a specific misconfiguration instead of discovered downstream in a dataset that looks fine until someone checks the currency symbol.
    • Expect this to be ongoing maintenance. Sites change their localization logic, CDNs get added, and a profile that worked last quarter can start returning the wrong locale with no warning and no error code.

    Geo-targeted scraping is really a consistency problem dressed up as a proxy problem. The proxy gets you into the right geography. Everything else is making sure the rest of the request agrees with it.

    If you’re building out region-specific scraping infrastructure and want to see how we think about proxy selection, request fingerprinting, and pipeline orchestration in more depth, browse the rest of our write-ups at Data Research Tools.

    Get new guides and videos first — join the Telegram channel.

  • Parsing PDFs and documents at scale: the infrastructure nobody tells you about

    Parsing one PDF is a solved problem. Parsing ten million of them, from a hundred different sources, with a deadline and a budget, is an infrastructure problem. Most teams find out the difference the hard way: the script that worked on a folder of 200 sample files falls over the moment it meets a real corpus, because real corpora are messy in ways sample folders never are.

    This is written from the operator’s side, the same side we run proxy pools and scraping pipelines from. Document extraction at scale has the same failure shape as any other high-volume pipeline: individual steps are easy, coordinating thousands of them without silent data loss is the actual job.

    Why “just use a library” stops working

    Every language has a solid PDF library. Python has PyMuPDF, pdfplumber, and pypdf. There’s Apache Tika if you want a JVM service that handles dozens of formats behind one interface. These libraries are good at what they do: open a file, walk its structure, hand you text and metadata.

    The problem is never the library call. It’s what happens around it at volume:

    • A single malformed PDF can hang a parser indefinitely instead of raising a clean exception, and if you’re processing serially, that one file stalls your entire batch.
    • Memory use per file is unpredictable. A 40-page PDF full of embedded images can spike RAM the same way a 4,000-page one does, so a worker sized for the “average” file OOMs on the outliers.
    • Encoding is inconsistent. Some PDFs embed a real text layer, some embed one that maps to garbage glyphs (common in older scanned-and-OCR’d government or legal filings), and some have no text layer at all.

    None of this shows up until you’re running thousands of files through the same code path. That’s the actual meaning of “at scale” here: not a bigger number, but a wider distribution of edge cases hitting the same pipeline in production.

    The two document types you’re actually dealing with

    Every PDF you’ll process falls into one of two buckets, and the pipeline has to detect which bucket before it decides what to do.

    Native text PDFs were generated by software, word processors, reporting tools, e-filing systems, and have a real text layer you can extract directly. Extraction here is fast and cheap, and the main risk is layout, not content: multi-column pages, footnotes, and running headers can get extracted out of reading order if you just pull raw text.

    Scanned or image-based PDFs are photographs or scans of paper pages with no text layer at all, or a text layer produced by lossy OCR done upstream by someone else. These need OCR run at your end, through something like Tesseract or a hosted OCR API, and OCR accuracy depends heavily on scan resolution, skew, and font. A blurry fax-quality scan will produce garbled output no matter how good the OCR engine is.

    A pipeline that assumes every document is type one will silently return empty strings for type two. That’s the single most common quiet failure in document pipelines: no crash, no error log, just an empty or near-empty extraction that looks like a valid result downstream.

    Layout-aware extraction versus raw text dumps

    Plain text extraction reads a page and hands you a string. That’s fine for a page of prose. It’s not fine for a table, an invoice, or a two-column academic paper, because the extractor doesn’t know what a table is, it just knows where characters sit on a coordinate grid.

    Layout-aware tools (pdfplumber’s table detection, Tika’s structured output, or ML-based layout parsers) try to reconstruct structure: which characters belong to which table cell, which text block is a header versus a footnote. This is meaningfully slower and heavier per document than raw extraction, which is why the right move is to route documents by type: raw text extraction for prose-heavy files, layout-aware extraction only for the subset that’s actually tabular or form-structured. Running the expensive path on every file is how a pipeline that should take hours takes days.

    The pipeline shape that actually survives volume

    The extraction logic is maybe 20% of the system. The other 80% is the scaffolding around it, and it looks the same whether you’re parsing PDFs, HTML, or any other bulk document source we deal with in scraping pipelines generally:

    • A queue, not a loop. One file failing shouldn’t block the next. Each document is a discrete job with its own retry count and timeout, pulled by a pool of workers rather than processed in a single serial script.
    • A hard per-file timeout. Kill any extraction that exceeds a few seconds to a minute (tuned to your typical file size) and route it to a dead-letter queue for manual or secondary handling, rather than letting it hang the worker.
    • Idempotent writes. Workers crash, get rescheduled, or get retried after a timeout. If reprocessing the same file twice can produce duplicate rows or corrupt state downstream, that will eventually happen and you’ll spend a weekend cleaning it up.
    • Checksums for dedup. Large corpora, especially ones pulled from multiple sources over time, contain the same document more than once. Hashing the file (not just the filename) before you spend compute extracting it saves real money at scale.
    • Structured logging per document, not per batch. When 200 out of 50,000 files failed, you need to know which 200 and why, not just an aggregate failure count.

    None of this is exotic. It’s the same durability pattern you’d apply to any large async job system. The mistake is treating document parsing as a script instead of a service.

    Where fetching and access fit in

    A lot of “document parsing at scale” projects actually start upstream of parsing: pulling the documents themselves from portals, filing systems, or archives before you can extract anything. That fetching step has its own failure modes, rate limits, session handling, and access controls that exist for legitimate operational and legal reasons.

    We won’t lay out bypass techniques here. What’s worth knowing as an operator is that document portals increasingly use the same detection layers as general web scraping targets: request rate thresholds, session and cookie validation, and behavioral fingerprinting on the client making the request. If your fetching step gets throttled or blocked, that’s usually the access layer doing its job, not a bug in your extraction code, and the fix is to slow down, respect documented rate limits, and use accounts and access you’re actually authorized to use rather than trying to outrun the defense. Building a fetch pipeline that only ever pulls documents you have a legitimate right to access is the part that actually scales sustainably, because it doesn’t end with your access revoked mid-project.

    Failure modes worth planning for up front

    A few things reliably break large document pipelines, and it’s cheaper to design for them than to discover them in production:

    • Password-protected or encrypted PDFs that your extractor will reject outright unless you handle that branch explicitly.
    • Corrupt or truncated files from interrupted downloads, which need a validity check before they ever reach the extraction worker.
    • Version drift in the source format. A form template that changes layout six months into a project will silently shift your table-column mapping if you don’t re-validate extraction quality on a rolling sample.
    • Language and font issues. Non-Latin scripts and embedded custom fonts can produce mis-mapped characters in extraction even when the file itself is perfectly valid.

    The fix for all four is the same discipline: sample and manually check extraction output on an ongoing basis, not just at the start of the project. Extraction quality degrades quietly when the input distribution shifts, and nothing in a typical pipeline alerts you to that on its own.

    What “at scale” really costs

    Scale here isn’t just compute. It’s the engineering time spent on routing logic, dead-letter handling, and ongoing quality sampling, plus the compute cost of OCR, which is far more expensive per page than native text extraction. A corpus that’s 90% native-text PDFs and 10% scans will cost far more than 10% extra to run, because that 10% carries almost all the OCR compute. Budgeting a document pipeline honestly means estimating the split between those two buckets before you estimate anything else.

    None of this is a reason to avoid the work. It’s a reason to build the queueing, retry, and monitoring layer before you scale up volume, instead of after the first production run buries you in silent failures.

    If you’re building or debugging scraping and document pipelines and want more breakdowns like this on infrastructure that actually holds up at volume, check out the rest of what we cover at Data Research Tools.

    Get new guides and videos first — join the Telegram channel.

  • Scraping behind a login without breaking the account

    Most scraping guides assume you’re pulling public pages. The moment you have to log in first, the problem changes shape. You’re no longer an anonymous request bouncing between IPs. You’re a specific account, with a specific history, doing something a human on that account probably wouldn’t do at that speed. This is authenticated scraping, and it fails differently than open-web scraping does.

    Why logged-in scraping is a different problem

    An unauthenticated scraper’s worst outcome is usually an IP block. You rotate, you continue. An authenticated scraper’s worst outcome is the account itself getting suspended, rate-limited, or permanently banned. If that account belongs to a paid subscription, a business relationship, or a real person’s identity, losing it costs more than a blocked IP ever would.

    The reason is architectural. Once you’re logged in, the site doesn’t need to guess who you are from your IP or your TLS fingerprint. It already knows. Every request you make gets tied to a session token, and that session token is tied to an account with a full history: when it usually logs in, what it usually clicks, how fast it usually moves between pages. Anomaly detection at the account level doesn’t need to be clever. It just needs a baseline and a threshold.

    What a session actually is

    A session isn’t just a cookie. Depending on the site, it’s some combination of:

    • A session cookie or bearer token issued at login
    • CSRF tokens tied to that session, often rotating per form or per request
    • Device or browser fingerprint data collected at login and re-checked periodically
    • Server-side state: login timestamp, last-seen IP, last-seen user agent

    When any of these drift too far from what was recorded at login, the server has a decision to make: silently degrade the session (serve a login wall on the next request), force a re-authentication, or flag the account for review. Which one happens depends entirely on the site’s own risk engine, and that engine is invisible to you until it fires.

    This is why “just grab the cookie and hit the API” scrapers break constantly. They treat the session as a static credential instead of a live object the server is watching.

    The signals that get accounts flagged

    From the defensive side, the systems built to catch this kind of activity generally watch for:

    Velocity mismatches. A human reading a dashboard clicks through pages with pauses, scrolls, and dead time. A script that pulls forty pages in three seconds produces a request pattern no human session on that account has ever produced. Rate anomaly detection compares against the account’s own history, not just a global threshold.

    IP or location churn on a single session. If a session token issued to an IP in one country suddenly starts making requests from a datacenter IP on another continent, that’s a strong signal, regardless of how convincing the browser fingerprint looks. Some platforms bind the session loosely to the IP range at issuance and re-verify on drift.

    Endpoint sequencing. Real users navigate in patterns: they land on a dashboard, click into a specific record, then maybe export it. A scraper that hits an internal API endpoint directly, skipping the pages a browser would normally load first, is taking a path that doesn’t exist in the site’s own navigation graph. That’s detectable independent of speed.

    Fingerprint instability. If the same session presents a different TLS fingerprint, a different set of HTTP header ordering, or a different canvas/WebGL signature between requests, that inconsistency is itself a signal, separate from whatever the fingerprint values actually are. A real browser is boringly consistent across a session; automation frameworks that don’t control for this leak variance.

    Concurrent session anomalies. One account logged in from two supposedly different devices, making requests in an interleaved pattern that no human juggling two tabs would produce, is a common giveaway for scraped accounts shared across a scraping operation.

    None of these signals alone is proof. Risk engines score and combine them, and the threshold for action varies by site, by account tier, and by how much that platform has invested in this kind of detection. This is exactly why nobody, including us, can promise a specific technique won’t trip it. We don’t have visibility into any given site’s scoring model, and neither does anyone selling you a tool that claims otherwise.

    What actually reduces account risk

    Given that the underlying risk can’t be eliminated, the practical question is how to keep your access pattern from standing out as automated in the first place.

    Match the request cadence to the content. If the workflow requires paginating through a hundred records, and a human would spend two or three seconds per page reading it, a scraper that mimics that pacing produces a session log that looks like ordinary usage. This costs you throughput. That’s the tradeoff, not a workaround.

    Reuse the same session and don’t re-authenticate more than a real user would. Logging in fresh for every scrape run generates a login event every time, and login events are themselves scored (new device, new IP, time of day). A session that persists across a scraping job, with cookies and tokens carried forward, produces one login event instead of dozens.

    Keep the network path consistent for the life of a session. If a session was issued to a residential IP in one city, keep that session on that same IP, or at minimum the same general network, for its duration. Switching the underlying proxy mid-session is one of the fastest ways to trigger a re-verification or a forced logout, because it’s a discontinuity the server can see directly in its own logs.

    Respect the site’s own rate signals. A 429 response or a sudden CAPTCHA challenge on a normally CAPTCHA-free flow is the platform telling you it noticed. Backing off at that point, rather than retrying immediately, is the difference between a temporary slowdown and a permanent flag.

    Only scrape what the account is actually authorized to see. This isn’t a technical point, it’s the boundary that matters most. If a login only exists to access licensed or paywalled content the account owner is entitled to under the platform’s terms, staying within that scope keeps the activity on solid ground. Scraping someone else’s authenticated view, or pulling data a subscription tier doesn’t actually cover, is a different problem than the infrastructure question this article is about, and it’s not one proxies or better session handling solve.

    Where this fits in a pipeline

    If you’re already running scraping infrastructure with proxy rotation for unauthenticated jobs, the instinct is to bolt the same rotation logic onto authenticated jobs. That’s usually the wrong move. Unauthenticated scraping benefits from spreading load across many IPs because each request is independent. Authenticated scraping benefits from the opposite: session stickiness, where one account maps to one consistent network identity for as long as that session lives.

    This means your orchestration layer needs to treat authenticated jobs as a distinct class, with session-to-IP binding tracked and enforced, separate from whatever rotation pool handles your anonymous crawling. Sharing a rotation pool across both job types is a common way sessions end up jumping IPs mid-run without anyone noticing until the account gets flagged.

    It also means logging matters more here than in anonymous scraping. When an authenticated job starts failing, “which proxy was this session bound to, and did it change” is usually the first question worth answering, before assuming the site changed its detection.

    The honest limit

    There’s no configuration that makes an account un-flaggable, and no proxy type that guarantees a session survives indefinitely. What you’re doing when you scrape behind a login is generating a stream of behavioral data on someone else’s server, and that server’s owner gets to decide what looks normal. The best you can do is not manufacture obvious discontinuities, and accept that the ceiling on how much you can pull through one account is set by how close to human usage you’re willing to keep it.

    If you want the deeper technical breakdowns on session handling, fingerprinting, and how detection systems are actually built, we cover it in more depth on the channel and the rest of the site.

    Back to Data Research Tools

    Get new guides and videos first — join the Telegram channel.

  • Logging and observability for data pipelines

    The failure mode nobody logs for

    Most pipeline outages announce themselves. A process crashes, a container restarts, an on-call phone buzzes. The failures that actually cost you data are quieter than that. A scraper hits a target, gets a 200 response, and writes a record to the database. Everything downstream thinks the job succeeded. Except the payload was a CAPTCHA page, or a soft-block message dressed up as normal HTML, or an empty results array because the site changed a query parameter. Nothing crashed. Nothing alerted. The data just quietly stopped being real.

    If you run scraping infrastructure or any pipeline that depends on a moving external system, this is the failure mode that matters most, and it’s the one that generic logging setups miss by default. HTTP status codes tell you whether the connection worked. They don’t tell you whether the content is what you expected. Building observability that catches this gap is a different exercise than bolting on a logging library and calling it done.

    Three layers, not one log stream

    A data pipeline has at least three distinct things worth tracking, and conflating them into one undifferentiated log stream is how teams end up with dashboards nobody trusts.

    Request-level logs. Every fetch: target URL, status code, response time, response size, which proxy or exit node handled it, and whether TLS or connection errors occurred. This is the layer that tells you the network path is healthy.

    Content-level logs. Whether the response actually contained what the parser expected. Did the expected fields exist? Did the record count match a reasonable range? Did the schema match the last known-good shape? This layer catches the CAPTCHA-page-with-200-status problem, because it validates content rather than trusting the transport layer.

    Pipeline-level logs. Queue depth, worker throughput, retry counts, dead-letter volume, time from fetch to stored record. This layer tells you whether the system as a whole is keeping up, independent of whether any single request succeeded.

    Teams that only log the first layer get paged when the connection breaks and blindsided when the content quietly rots. The content layer is the one that requires actual engineering effort, because it means writing assertions specific to each target: expected field presence, expected value ranges, expected record counts per page. It’s tedious per-target work, but it’s the only layer that catches silent degradation.

    Structure your logs like you’ll query them, because you will

    Plain text log lines that get grepped in a crisis are fine for a single script running on one machine. They stop working the moment you have more than a handful of workers or more than one proxy exit point, because “grep for the error” turns into “grep for the error, then manually cross-reference it against the proxy IP”.

    Structured logging (JSON lines, or a schema’d log format) makes this a query rather than a manual correlation. Each log entry should carry consistent fields: timestamp, target, proxy or exit identifier, status code, response time, content-validation result, and a job or run ID that ties it back to the pipeline execution that produced it. Once logs are structured this way, you can ask questions that a text search can’t answer cleanly: what’s the failure rate for this target broken down by proxy pool over the last six hours? Is the content-validation failure rate rising for one specific parser while others stay flat?

    That last question is usually the more important one. A pipeline running against ten targets doesn’t fail all at once. It fails one target at a time, quietly, while the aggregate dashboard shows a healthy 95% success rate across everything else. Aggregate metrics hide exactly the failures you need to see first. Break every metric down by target and by proxy pool, not just in total.

    Metrics that actually predict trouble

    Uptime and total request count are the least useful numbers on a scraping dashboard. The metrics worth building alerts around are the ones that move before a full outage does:

    • Success rate per proxy pool, not overall. A single degraded subnet or a batch of flagged exit IPs will drag down results long before the whole pipeline looks unhealthy in aggregate.
    • Response time percentiles (p50/p95/p99) rather than averages. Averages get pulled down by the bulk of fast, cached responses and hide the tail of slow or throttled requests that’s often the earliest sign of a target tightening its defenses.
    • Content-validation failure rate over time, tracked per target. A site that starts serving a slightly different HTML structure, or that starts injecting anti-bot challenge pages for a subset of requests, shows up here well before it shows up in raw error counts.
    • Retry and dead-letter volume. A rising retry rate on jobs that eventually succeed is a leading indicator of a target getting harder to reach, even while your overall completion rate still looks fine.
    • Schema drift. Logging the shape of the data you extract (field presence, types, cardinality) and diffing it against a baseline catches upstream changes before they cascade into broken downstream reports.

    Alert on the derivative, not the level

    The single most common observability mistake in pipeline ops is alerting on a static threshold: “page me if success rate drops below 90%.” That threshold is either too loose to catch anything useful or so tight that it fires constantly on normal variance, and either way the team stops trusting it within a month.

    What actually predicts trouble is the rate of change. A success rate that drops from 98% to 94% over twenty minutes is a much stronger signal than a success rate that’s sat at 92% for a week because that’s just what this particular target looks like. Track a rolling baseline per target and alert on deviation from that baseline, not on an absolute number picked once and never revisited. This also means your alerting has to be target-aware. A payment gateway and a public product listing page have completely different normal failure rates, and one threshold across both will be wrong for at least one of them.

    What detection systems watch for, and why it cuts both ways

    It’s worth understanding that the same observability principles run in the other direction. Anti-bot and fraud-detection systems are, structurally, an observability pipeline pointed at your traffic instead of at a target’s content. They log request timing, header consistency, TLS fingerprint stability, and behavioral patterns across sessions, and they alert on deviation from what an ordinary browser looks like, exactly the derivative-based alerting described above. A sudden burst of requests with unnaturally consistent timing, or a proxy pool whose IPs get flagged in threat-intelligence feeds, reads to the target’s systems the same way a spike in your own error logs reads to you: as a signal something changed.

    That’s a defensive fact worth knowing, not a checklist for evasion. There’s no proxy, header rotation, or request pattern that makes automated access invisible to a system built to detect exactly that pattern, and no infrastructure vendor can honestly promise otherwise. What good observability on your own side actually buys you is faster detection when a target’s defenses shift, so you find out from your own content-validation logs rather than from a client asking why the data went stale three weeks ago.

    Building this incrementally

    None of this needs to ship at once. The highest-value first step is almost always content-level validation on your highest-volume targets, because that’s the layer generic logging tools don’t give you for free. After that, structure the logs so failure rate can be sliced by proxy pool and by target, since aggregate numbers hide the failures that matter. Baseline-relative alerting can come last, once you actually have enough historical data per target to know what normal looks like.

    Observability for a data pipeline isn’t a dashboard you buy. It’s a set of assumptions about what “working” means, made explicit enough that you find out when they stop being true.

    Data Research Tools writes about the infrastructure behind scraping and pipeline work from the operator’s side, not the marketing side. Browse more explainers and tool reviews on the home page.

    Get new guides and videos first — join the Telegram channel.

  • Browser Pool Management and Memory Leaks: Why Scrapers Die at Scale

    The problem nobody mentions in the tutorial

    Every browser automation tutorial shows you how to launch a headless Chrome instance, navigate to a page, and pull some data. None of them show you what happens when that instance has been running for six hours across ten thousand page loads. That’s the gap between a demo script and a production scraping pipeline, and it’s almost entirely about memory.

    We run scraping infrastructure against real targets, at real volume, on real proxy infrastructure. The thing that takes down a pipeline is almost never the scraping logic. It’s the browser process quietly eating RAM until the host OOM-kills it, or a pool of “idle” contexts that never actually got torn down, or a queue of jobs stalling because every worker is waiting on a browser that’s already dead. This is an explainer on how browser pools actually work, why they leak, and what a defensible design looks like. It is not a guide to evading anything a target site does to detect automation.

    What a browser pool actually is

    A browser pool is a fixed set of browser processes (or browser contexts within fewer processes) that get reused across scrape jobs instead of spinning up a fresh browser for every request. The reasoning is simple: launching Chromium is expensive. Cold start on a headless Chrome instance typically runs several hundred milliseconds to a few seconds depending on flags and host load, and that’s before you’ve navigated anywhere. If you’re processing thousands of pages an hour, paying that cost per page destroys your throughput.

    So instead, pipelines hold a pool of N browser instances (or M contexts per instance, since Playwright and Puppeteer both support isolated BrowserContext objects inside a single browser process) and hand them out to workers as jobs come in. When a job finishes, the context gets returned to the pool rather than destroyed, ready for the next job.

    That reuse is exactly where the trouble starts.

    Why Chromium leaks under sustained use

    Chromium was built to run as a single long-lived desktop application with a user closing tabs and occasionally restarting it. It was not built to survive being driven headlessly through tens of thousands of navigation cycles without ever fully restarting. A few concrete mechanisms cause the memory curve to trend upward over time:

    Detached DOM trees. When a page navigates away, V8 should garbage-collect anything the old page held. In practice, event listeners attached by injected scripts, closures held by extensions or CDP (Chrome DevTools Protocol) instrumentation, and unresolved promises can keep references alive across navigations. The old document doesn’t get freed even though nothing on screen references it anymore.

    Renderer process accumulation. Chrome’s multi-process architecture spins up separate renderer processes per site instance for isolation. In a long-running pool, especially one navigating across many different origins, you can end up with more renderer processes alive than you’d expect, each holding its own JS heap, until the browser’s own reclamation logic catches up (if it does before you’ve moved on).

    CDP session leaks. Puppeteer and Playwright both drive Chrome over the DevTools Protocol. Every page, network intercept, or console listener you attach through CDP is a subscription. If your automation code creates a new listener per navigation and never removes the old one, you’re accumulating listeners for the lifetime of the browser process, not the lifetime of the page.

    Cache and storage growth. HTTP cache, IndexedDB, and localStorage per context are supposed to be capped, but a pool that reuses the same context across many jobs (rather than issuing a fresh context per job) will accumulate storage state for every distinct domain it visits in that session.

    None of this is a bug in the sense of “Chromium is broken.” It’s a mismatch between how the browser was designed to be used and how a scraping pipeline actually uses it: not a human closing tabs, but a scheduler cycling through jobs as fast as the network allows, indefinitely.

    The symptoms before the crash

    The failure mode is rarely a clean crash. It’s a slow degradation:

    • Job latency creeps up because the browser is spending more time in garbage collection.
    • Memory-mapped page counts on the host climb and the OS starts swapping, which slows every process on the box, not just the browser.
    • Eventually the kernel OOM-killer picks the Chrome process (or the whole container) to reclaim memory, and every job currently assigned to that browser instance fails at once.
    • If your worker doesn’t detect the dead browser and just tries to reuse a dead page handle, you get cascading errors that look like target-site problems but are actually pool problems.

    This last one is the trap. Teams debugging “why is the scraper suddenly getting errors on this site” often spend hours looking at the target before realizing the browser process backing that worker died forty minutes ago.

    Patterns that actually hold up

    Recycle by count, not by uptime alone. Kill and relaunch a browser instance after it has handled some fixed number of jobs or navigations, regardless of how “fine” it looks. Uptime-based recycling is easier to reason about at first, but a browser that’s handled 50,000 navigations in an hour is in a different state than one that’s been idle for an hour. Count-based recycling tracks actual internal churn.

    One context per job, not one context forever. Reuse the browser process (that’s where the cost saving is) but create a fresh BrowserContext per job and close it when the job ends. Contexts are cheap to create and destroy compared to full browser processes, and closing one releases its storage, cookies, and most of its JS heap. This alone eliminates a large share of the leaks that come from long-lived context reuse.

    Track memory per instance, not just per host. A pool-level supervisor that polls RSS (resident set size) for each Chrome process and retires any instance crossing a threshold catches leaks before they become OOM events, instead of reacting after the kernel already intervened.

    Health-check before handing out a browser, not just when it errors. A worker pulling from the pool should verify the browser is still responsive (a lightweight CDP ping, or checking the process is alive) before assigning a job to it. Otherwise you hand a job to a browser that died three jobs ago and the failure surfaces as a job-level timeout with no clear cause.

    Cap concurrent contexts per browser process. Running too many contexts simultaneously in one process multiplies renderer overhead. There’s a real ceiling, and it’s lower than “however many the pool config says,” because it depends on how heavy the pages you’re rendering actually are (image-heavy pages, videos, and heavy JS frameworks all cost more per context).

    Log and alert on pool exhaustion, not just pool errors. If every browser in the pool is marked busy and jobs are queuing, that’s a leading indicator something upstream is holding contexts too long, whether from a slow target site, a stuck navigation with no timeout, or a leak that’s made every “recycled” browser fine on paper but effectively unusable.

    Where this connects to the rest of the stack

    Browser pool stability isn’t separate from proxy management or fingerprinting concerns, it interacts with both. A browser instance that’s been reused across thousands of navigations accumulates state that can make its fingerprint inconsistent with a fresh session, and if that instance is also being routed through the same proxy exit for that entire span, you’ve coupled two different kinds of session hygiene into one failure. Sites that run bot detection are, in part, looking for exactly this kind of anomaly: a browser context whose storage state, TLS session tickets, and navigation history don’t line up with a plausible single user session. That’s the target’s defense working as designed, and it’s a separate topic from what this piece covers. Here, the point is narrower: keep your infrastructure itself from falling over under its own weight before you even get to that layer.

    None of this makes a scraping operation “safe” in some absolute sense, and none of it should be read as a way around anything a site puts in place to detect automated traffic. It’s the operational discipline that keeps a pipeline running long enough to be worth operating at all.

    If you’re building or debugging scraping infrastructure and want the rest of our engineering notes on proxy management, pipeline orchestration, and tool reviews, check out the Data Research Tools homepage.

    Get new guides and videos first — join the Telegram channel.

  • What a sitemap gives you that crawling does not

    Most scraping pipelines start with a crawler because that’s the default mental model: point something at a homepage, follow links, keep a frontier queue, repeat until you’ve covered the site. It works, but it’s the expensive way to solve a problem the site has often already solved for you. A sitemap is the site telling you, in a structured file, which URLs exist and roughly when they last changed. If you skip it, you’re rebuilding that index by brute force, one page at a time, at a cost the site pays too in the form of requests it didn’t need to serve.

    This isn’t an argument that sitemaps replace crawling. It’s an argument that they solve a different part of the problem, and most pipelines that only crawl are doing unnecessary work.

    What a sitemap actually is

    A sitemap is an XML file, usually at /sitemap.xml, that lists URLs inside a <urlset> element. Each <url> entry can carry a <loc> (the URL itself), a <lastmod> (when it was last modified), and legacy fields like <changefreq> and <priority> that search engines mostly ignore now but that CMS platforms still emit out of habit. Large sites don’t ship one file. They ship a sitemap index, a file that points to dozens or hundreds of child sitemaps, because the protocol caps a single sitemap at 50,000 URLs. WordPress plugins like Yoast, Shopify’s storefront, and most static site generators produce these automatically, split by content type: sitemap-posts.xml, sitemap-products.xml, sitemap-categories.xml, and so on. robots.txt often declares where the sitemap lives with a Sitemap: line, which is the first place to check before assuming a site doesn’t have one.

    The file is self-reported. Nobody crawled the site to build it. A CMS generated it from its own database at publish time. That’s the whole value proposition: the site already knows its own URL inventory, and the sitemap is that inventory handed to you directly instead of you inferring it from link graphs.

    The list you don’t have to build yourself

    A crawler discovers URLs by following <a href> tags across pages it has already fetched. That means coverage depends on internal linking. A page that exists but isn’t linked from anywhere reachable in your crawl depth simply doesn’t get found. Pagination, faceted navigation, and JavaScript-rendered link menus all add friction, because now you either need to paginate through listing pages or run a headless browser to see links that only appear after client-side rendering executes.

    A sitemap sidesteps all of that. If a product page is in the sitemap, you have its URL, full stop, regardless of whether any other page on the site links to it. This matters most on large e-commerce and content sites where orphaned or deep-catalog pages are common: old product variants, archived articles, seasonal listings that got unlinked from navigation but never taken down. For anyone building a pipeline meant to track a catalog or archive rather than one meant to mirror the visible link structure of a homepage, the sitemap is a more complete and far cheaper starting point.

    It’s also fewer requests. A crawl of a 20,000-page site touches 20,000 pages plus every intermediate listing and pagination page it takes to discover them. Reading a gzipped sitemap index is a handful of requests that returns the full URL list in one shot. That difference in request volume is the difference between a pipeline that’s a light, occasional visitor and one that’s generating meaningful load on someone else’s infrastructure.

    Lastmod: the field that makes deltas cheap

    The single most useful field in a sitemap for anyone running a recurring scrape is <lastmod>. If you’re tracking price changes, article updates, or inventory status over time, the naive approach is to re-fetch every page on every run and diff the content yourself. That’s expensive and it scales badly as the tracked set grows.

    lastmod lets you skip pages that haven’t changed. Store the timestamp you saw last run, compare it against the current sitemap, and only re-fetch URLs where the value moved forward. On a site where most pages are static most of the time, this turns a full re-crawl into a small delta fetch. It’s also the more polite way to run a recurring job: you’re only pulling pages that actually changed, instead of hammering the entire catalog on a schedule regardless of whether anything moved.

    The caveat, and it’s a real one: lastmod accuracy varies a lot by platform. Some CMS setups update it correctly on content edits. Others set it to the build or deploy timestamp for every page in the sitemap, which makes the field useless for change detection because it moves for reasons that have nothing to do with the content itself. There’s no way to know which behavior a given site has without checking it empirically, comparing lastmod values against pages you can independently confirm changed. Treat the field as a hint to prioritize checks, not as ground truth you can skip verification against.

    Where sitemaps fall short

    Sitemaps are not a complete substitute for crawling, and treating them as one will leave gaps. A few concrete limits:

    The file only contains what the site chose to include. Pages excluded from the sitemap by the CMS, whether deliberately or through a config bug, simply won’t show up, and a sitemap gives you no signal that anything is missing. A crawl following actual page links can surface content a sitemap omits.

    lastmod, changefreq, and priority are all self-reported and unverified. A site under no obligation to keep them accurate, and plenty don’t bother once the fields stop mattering for search ranking.

    A sitemap tells you a URL exists and roughly when it changed. It tells you nothing about what changed within the page. You still need to fetch the page and parse it to know if the price moved, the stock status flipped, or it was just a typo fix in a footer.

    Large or poorly maintained sites sometimes ship stale sitemap indexes that reference child sitemaps returning 404s, or that haven’t been regenerated after a migration. Validate the index resolves before building a pipeline around it.

    None of this is a defense-evasion concern. It’s the same category of problem as any external data source: the file describes what the publisher chose to expose, and your pipeline needs to treat it as an input to verify, not an oracle to trust blindly.

    Sitemaps and crawling are not either/or

    The strongest pipelines use both, in sequence. Pull the sitemap first to get the bulk of the URL inventory cheaply and to prioritize which pages to check based on lastmod. Then run a lighter, periodic crawl of key navigation and listing pages to catch anything the sitemap missed, orphaned pages, newly added sections not yet reflected in the sitemap, or content behind pagination the sitemap generator didn’t index. The crawl becomes a completeness check on top of the sitemap’s speed, rather than the primary discovery mechanism.

    This division also changes what your infrastructure needs to look like. A sitemap-driven fetch of a few hundred changed URLs a day is a small, predictable job. A full crawl of the same site, run on the same schedule, is a much larger and more visible pattern of requests. If you’re running this against sites you don’t operate yourself, minimizing request volume through sitemap-first discovery is both the cheaper approach and the more considerate one, and it’s worth checking a target’s robots.txt and terms before deciding how aggressively to pull either way.

    The honest limits

    None of this is a claim that sitemap-based discovery is invisible or that any request pattern is risk-free. Fewer requests and politer scheduling reduce load and reduce how much a pipeline stands out, but they don’t make a scraper undetectable, and a sitemap file itself carries no guarantee about what a target site will or won’t do in response to being scraped at all. The decision to scrape a site, and how much of it, is a separate question from how you discover its URLs, and that separate question still needs a real answer: read the site’s terms, respect robots.txt directives, and don’t build a pipeline around content that’s paywalled, personal, or otherwise off limits.

    If you’re building or debugging a scraping pipeline and want the infrastructure side worked through in more depth, from proxy management to change-detection scheduling, Data Research Tools has more on how these systems are actually put together.

    Get new guides and videos first — join the Telegram channel.