SERP Scraping at Scale in 2026: Queues, Circuit Breakers & Caching

By Serpent API Team · · 13 min read

Almost every search-scraping project starts the same way: a single script that loops over a list of queries, fires a request at each, parses the page, and writes a row. It works beautifully — for the first hundred queries. Then you point it at ten thousand, and it falls apart.

The failures are predictable. Memory balloons as headless pages pile up. One slow target stalls the whole run. A wave of blocks turns into a retry storm that deepens the blocks. The same query gets scraped five times in a day because nothing remembers it ran. None of these are bugs in your parser — they are missing architecture.

This guide walks through the components a SERP scraper needs to survive real volume: a priority queue for concurrency, per-engine rate limiting with backoff, a circuit breaker to stop self-inflicted bans, a browser-lifecycle policy, and a cache to cut repeat cost. The patterns are generic and apply to any high-volume scraper. The numbers used throughout are illustrative starting points, not prescriptions.

TL;DR: A single-script scraper dies at volume because it has no concurrency control, no backoff, and no failure isolation. The fix is four pieces: a priority queue that caps concurrent work, per-engine rate limiting with exponential backoff and jitter, a circuit breaker that trips after N consecutive failures and cools down before probing, and a cache that serves repeat queries without touching the network. Recycle each browser roughly every couple of hundred pages, clean up temp dirs, and track yield — usable rows per attempt — not just HTTP success. Or skip all of it with a managed SERP API.

Why a single-script scraper dies at volume

A serial for loop has exactly one virtue: it is simple. At scale, every other property works against you. It runs one request at a time, so throughput is capped at one page per round-trip no matter how much CPU sits idle. It has no isolation, so a single target that hangs for thirty seconds blocks every query queued behind it.

It also has no memory between runs and no concept of failure. A 429 looks the same as success unless you check, so a naive loop sails past blocks and records empty rows — the silent-failure trap covered in building a resilient scraper that survives selector drift. And because nothing tracks which queries already ran, the same keyword gets re-scraped on every pass, multiplying your proxy bill for data you already had.

The instinct is to fix this by spawning everything at once — Promise.all over ten thousand queries. That is worse. You open ten thousand headless pages, exhaust memory in seconds, and trigger an instant block by slamming the target with a burst no human could produce. The answer is not zero concurrency or infinite concurrency. It is controlled concurrency, and that is what the rest of this guide builds.

Architecture overview

Picture the system as a pipeline with a few independent stages, each solving one problem. Work enters as jobs, a queue meters how many run at once, a rate limiter spaces requests per target, a circuit breaker pulls the plug when a target turns hostile, a cache short-circuits repeat work, and an observability layer tells you whether any of it is actually producing data.

ComponentProblem it solvesFailure it prevents
Priority queueCaps concurrent work, orders by importanceOut-of-memory, request bursts
Rate limiter + backoffSpaces requests per target/engineRate-limit bans, retry storms
Circuit breakerStops traffic to a failing targetSelf-inflicted long bans
Browser lifecycleRecycles pages and processesMemory leaks, zombie temp dirs
CacheServes repeat queries from storePaying twice for the same data
ObservabilityMeasures yield and block rateSilent data loss

The point of separating these is that each can be tested, tuned, and reasoned about on its own. A bug in your rate limiter does not corrupt your cache; a tweak to concurrency does not touch your backoff. Build them as small, composable pieces and the whole system stays debuggable as it grows.

Concurrency control with a priority queue

The first piece is a queue that runs at most N jobs concurrently and runs higher-priority jobs first. In Node, the p-queue library gives you both in a few lines — you set concurrency, and each add() call takes an optional priority where higher numbers run sooner.

// npm install p-queue
import PQueue from 'p-queue';

// At most 12 jobs in flight at once. Tune to your RAM and the
// target's tolerance — start conservative and watch memory.
const queue = new PQueue({ concurrency: 12 });

async function scrapeOne(query) {
  // ... launch a page, fetch, parse, return rows ...
  return { query, rows: [] };
}

function enqueue(query, { priority = 0 } = {}) {
  return queue.add(() => scrapeOne(query), { priority });
}

// Real-time, user-facing lookups jump the line (priority 10);
// nightly batch refreshes wait their turn (priority 0).
enqueue('mesothelioma lawyer', { priority: 10 });
for (const q of nightlyBatch) enqueue(q, { priority: 0 });

await queue.onIdle();   // resolves when every job has finished
console.log('all jobs done');

Two ideas make this powerful. First, concurrency is the single knob that protects your machine — set it to the number of pages your RAM can hold, and the queue never opens more, no matter how many jobs you push. Second, priority lets a latency-sensitive request skip ahead of a giant batch, so a user waiting on a live lookup is not stuck behind ten thousand nightly refreshes.

If you are in Python, the same shape comes from an asyncio.Semaphore(12) wrapped around each task, or a bounded worker pool consuming from an asyncio.PriorityQueue. The library differs; the principle — a hard cap on in-flight work, ordered by importance — is identical. For broader patterns, the open-source Crawlee framework bakes queueing and concurrency control into a full crawler.

Per-engine rate limiting and backoff

Concurrency caps how many requests run at once; rate limiting caps how often you hit a given target. These are different controls and you need both. Twelve concurrent pages all aimed at one engine is still a burst if they all fire in the same second — you want them spaced.

Rate limits are also per-target. Google, Bing, Yahoo, and DuckDuckGo each have their own tolerance, so a single global limit is either too slow for the lenient ones or too aggressive for the strict ones. Keep a limiter per engine. And when a request is throttled, the response to a 429 is not to retry immediately — that is the retry storm that turns a soft throttle into a hard ban, the exact dynamic in fixing Google's 429 unusual-traffic error.

The correct response is exponential backoff with jitter: wait longer after each failure, and randomize the wait so concurrent workers do not all retry in lockstep. Always honor a Retry-After header if the server sends one — it is telling you exactly how long to wait.

import asyncio, random, time
from collections import defaultdict

class PerEngineLimiter:
    """Minimum spacing between requests, tracked per engine."""
    def __init__(self, min_interval=1.0):
        self.min_interval = min_interval
        self._last = defaultdict(float)
        self._locks = defaultdict(asyncio.Lock)

    async def acquire(self, engine):
        async with self._locks[engine]:
            wait = self.min_interval - (time.monotonic() - self._last[engine])
            if wait > 0:
                await asyncio.sleep(wait)
            self._last[engine] = time.monotonic()

async def with_backoff(coro_factory, retry_after=None, max_tries=5):
    """Exponential backoff with full jitter; honors Retry-After."""
    for attempt in range(max_tries):
        try:
            return await coro_factory()
        except RateLimited as e:
            if attempt == max_tries - 1:
                raise
            base = e.retry_after or (2 ** attempt)   # 1, 2, 4, 8 ...
            delay = random.uniform(0, base)          # full jitter
            await asyncio.sleep(delay)

The two pieces work together: the limiter keeps you below the target's steady-state threshold, and the backoff handles the moments you cross it anyway. Tune min_interval per engine from observation — if a target tolerates more, lower it; if blocks climb, raise it. Pair this with the bandwidth discipline in cutting scraping bandwidth by blocking resources so each request is also as cheap as possible.

A circuit breaker for blocked targets

Backoff handles individual failures. A circuit breaker handles a pattern of failures — the moment a target stops being temporarily annoyed and starts actively blocking you. Without one, your scraper keeps throwing requests at a wall, every one of which deepens the block and burns proxy bandwidth for nothing.

The pattern, popularized by Michael Nygard and described well in Martin Fowler's circuit-breaker write-up, has three states. Closed is normal — requests flow. After N consecutive failures the breaker trips to open, where every call fails fast for a cooldown window without touching the network. When the cooldown elapses it moves to half-open and lets a single probe request through; if that succeeds the breaker closes and traffic resumes, and if it fails the cooldown starts again.

class CircuitBreaker {
  constructor({ threshold = 5, cooldownMs = 60_000 } = {}) {
    this.threshold = threshold;     // consecutive failures to trip
    this.cooldownMs = cooldownMs;   // how long to stay open
    this.failures = 0;
    this.state = 'closed';          // closed | open | half-open
    this.openedAt = 0;
  }

  async call(fn) {
    if (this.state === 'open') {
      if (Date.now() - this.openedAt < this.cooldownMs) {
        throw new Error('circuit open — failing fast');
      }
      this.state = 'half-open';     // cooldown elapsed: allow one probe
    }
    try {
      const result = await fn();
      this._onSuccess();
      return result;
    } catch (err) {
      this._onFailure();
      throw err;
    }
  }

  _onSuccess() {
    this.failures = 0;
    this.state = 'closed';          // probe (or normal call) succeeded
  }

  _onFailure() {
    this.failures += 1;
    if (this.state === 'half-open' || this.failures >= this.threshold) {
      this.state = 'open';          // trip, or re-trip after failed probe
      this.openedAt = Date.now();
    }
  }
}

Run one breaker per target so a blocked engine pauses only itself while the others keep flowing. The win is twofold: you stop hammering a target that has already said no, which gives a temporary cooldown the quiet it needs to expire instead of escalating, and you fail fast on the rest so blocked work does not clog your queue. It is roughly forty lines that prevent a whole category of self-inflicted outage.

Browser lifecycle and temp-dir cleanup

Headless Chromium is a memory glutton. Each page can hold hundreds of megabytes under load, and long-lived browser processes leak — memory creeps up over thousands of navigations until the process is killed by the OS or grinds to a swap-thrashing halt. The fix is to treat the browser as disposable and recycle it on a schedule.

A simple, robust policy is to restart the browser after roughly a couple hundred pages, or after a couple of hours, whichever comes first. The exact numbers are workload-dependent — watch your own memory curve and pick a threshold below the point where it climbs — but the principle is to recycle before the leak bites, not after a crash.

const puppeteer = require('puppeteer-extra');
const Stealth = require('puppeteer-extra-plugin-stealth');
const fs = require('fs/promises');
const os = require('os');
const path = require('path');
puppeteer.use(Stealth());

const MAX_PAGES = 200;            // recycle after ~200 navigations
const MAX_AGE_MS = 2 * 60 * 60 * 1000;   // ...or after 2 hours

let browser, userDataDir, pagesServed = 0, startedAt = 0;

async function getBrowser() {
  const stale = browser &&
    (pagesServed >= MAX_PAGES || Date.now() - startedAt > MAX_AGE_MS);
  if (stale) await recycle();

  if (!browser) {
    userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'scraper-'));
    browser = await puppeteer.launch({
      headless: 'new', userDataDir,
      args: ['--no-sandbox', '--disable-dev-shm-usage'],
    });
    pagesServed = 0;
    startedAt = Date.now();
  }
  return browser;
}

async function recycle() {
  try { await browser.close(); } catch (_) {}
  // Critical: delete the temp profile so disk doesn't fill up.
  try { await fs.rm(userDataDir, { recursive: true, force: true }); } catch (_) {}
  browser = null;
}

Note the temp-dir cleanup. Each browser gets its own userDataDir, and if you never delete those profiles a long-running scraper quietly fills the disk with abandoned directories until the host runs out of space — a classic 3 a.m. outage. Always remove the profile when you recycle. Increment pagesServed every time you hand out a page so the counter actually drives the restart.

A caching layer to cut repeat cost

The cheapest request is the one you never make. Most SERP workloads are far more repetitive than they look — rank trackers re-check the same keywords daily, dashboards reload the same queries, brand monitors poll the same terms. A cache keyed on the request, with a sensible time-to-live, serves those repeats from a local store instead of the network.

For a single process, an in-memory dict with timestamps is enough. For anything multi-process or persistent, reach for SQLite (zero-setup, file-backed) or Redis (shared, with native TTL). Here is a SQLite wrapper that caches by a hash of the normalized query parameters:

import sqlite3, json, time, hashlib

class SerpCache:
    def __init__(self, path="serp_cache.db", ttl=86_400):
        self.ttl = ttl                       # default: 1 day
        self.db = sqlite3.connect(path)
        self.db.execute(
            "CREATE TABLE IF NOT EXISTS cache "
            "(k TEXT PRIMARY KEY, v TEXT, ts REAL)"
        )

    def _key(self, query, engine, country):
        raw = json.dumps([query, engine, country], sort_keys=True)
        return hashlib.sha256(raw.encode()).hexdigest()

    def get(self, query, engine="google", country="us"):
        k = self._key(query, engine, country)
        row = self.db.execute(
            "SELECT v, ts FROM cache WHERE k = ?", (k,)
        ).fetchone()
        if row and time.time() - row[1] < self.ttl:
            return json.loads(row[0])        # fresh cache hit
        return None                          # miss or expired

    def set(self, query, value, engine="google", country="us"):
        k = self._key(query, engine, country)
        self.db.execute(
            "INSERT OR REPLACE INTO cache VALUES (?, ?, ?)",
            (k, json.dumps(value), time.time()),
        )
        self.db.commit()

# Usage: check the cache before you ever enqueue a scrape.
cache = SerpCache(ttl=6 * 3600)              # 6-hour freshness
hit = cache.get("best running shoes 2026")
results = hit if hit else scrape_and_store(cache, "best running shoes 2026")

The only real decision is the TTL, and it follows directly from how fresh the data must be: a rank tracker may be happy with a day, a news monitor may want minutes. Set it to the loosest value your use case tolerates and every repeat inside that window is free. This is worth a post of its own — see building a SERP cache to cut your API bill for cache invalidation, stale-while-revalidate, and Redis variants.

Observability: track yield, not just success

Here is the metric that separates a scraper that works from one that only looks like it works: yield. A 200 OK is not success — a target can return a perfectly valid page that contains a CAPTCHA, a consent wall, or zero results. If you measure HTTP status alone, you will report 99% success while quietly collecting empty rows.

Yield is usable, parsed rows divided by attempts. Track it per engine and watch it over time, because a sudden drop is your earliest warning that a selector changed or a block pattern shifted — long before the data quality problem reaches your dashboard.

MetricWhat it tells youActs as
HTTP success rateRequests that returned a 2xxNecessary, not sufficient
Parse yieldAttempts that produced usable rowsThe real health signal
Block rateResponses that were CAPTCHAs/consent/429Rate-limit early warning
Cache hit rateRequests served without the networkCost-savings tracker
Breaker tripsTargets that went openWhere to back off harder
p95 latencyTail request time per engineCapacity-planning input

Emit these as structured logs or metrics and alert on yield, not just errors. A scraper that throws is easy to notice; a scraper that succeeds into a void is the dangerous one. This is the operational backbone behind a resilient scraper that survives selector drift — fail loud, measure what matters, and you find out about breakage from a graph rather than from an angry stakeholder.

The build-vs-buy reality

Step back and look at what you have built. A priority queue, a per-engine rate limiter, exponential backoff, a circuit breaker, browser lifecycle management, temp-dir hygiene, a cache, and an observability layer — plus the proxy pool, the parsers, and the on-call rotation for when a target changes its markup on a Tuesday. That is a real distributed system, and it is not your product.

The honest question is whether maintaining this infrastructure is the best use of your engineering time. For a team whose core business is search data, maybe. For most teams, the queue, the breakers, and the proxy budget are pure overhead on top of whatever they are actually trying to build. The full cost — engineer hours, proxy bandwidth, and the maintenance tax — is laid out in running SERP data at scale and the broader web scraping versus a SERP API comparison.

A managed SERP API collapses every box in the architecture diagram into a single HTTP call. The queue, rate limiting, backoff, breakers, browser recycling, and proxies all live behind the endpoint; you send a query and get clean JSON back:

import requests

resp = requests.get(
    "https://api.apiserpent.com/api/search",
    headers={"X-API-Key": "sk_live_your_key"},
    params={"q": "mesothelioma lawyer", "engine": "google", "country": "us"},
)

for r in resp.json()["results"]["organic"]:
    print(r["position"], r["title"], r["url"])
# No queue, no breaker, no proxy budget — just results.

Your own caching layer still pays off on top of an API, since a cache hit is a call you do not spend either way. The difference is that everything below the cache — the part that pages you at 3 a.m. — is no longer yours to run.

The verdict

A single script is the right tool for a one-off job of a few hundred queries. The moment you need continuous volume, the architecture in this guide is not optional gold-plating — it is the difference between a scraper that quietly produces data for months and one that burns proxy budget into a void and falls over on its first bad night.

If you do build it yourself, build the four core pieces in order of pain: the queue first (it stops the out-of-memory crashes), then rate limiting and backoff (they stop the bans), then the circuit breaker (it stops the self-inflicted long bans), then the cache (it cuts the bill). Wrap the whole thing in yield-based observability so you find out about breakage from a metric, not a missing report.

And weigh it honestly against buying. Caching aside, almost every component here exists only because you chose to do the access yourself. If search data is an input to your product rather than the product, a managed API lets you delete the entire diagram and get back to what you are actually building.

Skip the queue, the breakers, and the proxy budget.

Serpent's SERP API returns clean JSON from Google, Bing, Yahoo & DuckDuckGo — no proxies, no CAPTCHAs, no parser maintenance. Get 10 free searches on signup, then pay-as-you-go from $0.03 per 10,000 searches at scale, no subscription.

Get Your Free API Key

Explore: SERP API · Pricing · Playground

FAQ

When does a single script stop scaling?

Usually somewhere between a few hundred and a few thousand requests a day, depending on the target. A serial for-loop is fine for a one-off job, but it has no concurrency, no rate limiting, no backoff, and no isolation between jobs, so one slow target or one wave of blocks stalls everything behind it. The moment a failure in one request affects the throughput or correctness of the rest, you have outgrown the single script and need a queue, rate limits, and a circuit breaker.

Do I really need a circuit breaker?

If you scrape one target casually, no. If you run continuous volume against a target that can start blocking, yes. Without a breaker, a target that begins returning blocks gets hammered by every retry your scraper makes, which deepens the block, wastes proxy bandwidth, and can extend a temporary cooldown into a long ban. A circuit breaker trips after a threshold of consecutive failures, stops sending traffic for a cooldown window, then probes with a single request before resuming. It is a small class that prevents a large class of self-inflicted outages.

How much does caching save?

It depends entirely on how repetitive your queries are, but for rank tracking, brand monitoring, and dashboards the repeat rate is often very high, and a cache with a sensible time-to-live can serve a large share of requests without touching the network at all. Every cache hit is a request you did not pay proxy bandwidth or an API call for, so even a modest hit rate pays for the small amount of code a cache layer takes. The key is to set the time-to-live to match how fresh the data actually needs to be.

How many concurrent browser pages can I run?

Fewer than you would guess. Each headless Chromium page can use hundreds of megabytes of RAM under load, so a single machine usually tops out in the low double digits of pages before it starts swapping or crashing. A common, conservative starting point is around a dozen concurrent pages per instance, tuned up or down by watching memory and CPU. Concurrency is also bounded by the target's rate limits, not just your hardware, so the right number is the lower of what your machine can hold and what the target tolerates.