How SERP works

A SERP API is a browser that reads search pages for you and hands back the rows. SERP means search engine results page: the page Google or Bing shows after you type a query. Google, Bing and the rest do not offer their results as a free JSON feed, so a SERP API loads that page the way a person would, finds the result blocks in the HTML, and turns each into {rank, title, url, snippet}.

This article walks one request through OpenSERP, an MIT-licensed Go server that does this for Google, Bing, DuckDuckGo, Yandex, Baidu and Ecosia. All numbers below were measured on 2026-10-28: OpenSERP at commit b6f9637 (v0.8.15 + 3 commits) built from source, run in Docker on one home connection, no proxy.

MeasuredResult
One search, browser mode1.4–3.7 s mean per engine
Same search again (cache hit)< 1 ms
Four engines at once (/mega/search)3.6 s, 37 rows → 34 unique URLs
Google without a browser (raw HTTP)blocked, 4 of 4 queries

The path of one request

GET /google/search?text=how+does+tcp+slow+start+work&limit=10
The path of one search request through a SERP API. The request first checks a 120-second cache; a hit returns in under a millisecond. A miss passes the circuit breaker and the per-engine rate limiter, then the engine URL is built, an isolated headless Chrome tab loads it, the page is classified as results, captcha, block or empty, the result blocks are parsed, redirect links are unwrapped, and the rows are normalised into JSON and cached. One request, cache miss left column = the main path · right column = where a step leaves it or calls out GET /google/search?text=…&limit=10 1 · cache lookup key = engine + query + all parameters; TTL 120 s hit return cached JSON · < 1 ms 2 · circuit breaker + rate limiter 60 req/min per engine, burst 3 3 · build the engine URL q, oq, pws=0 … + hl, gl, num, start when set 4 · load it in an isolated Chrome tab fresh context, browser profile, no images/CSS/fonts www.google.com/search returns HTML + JavaScript 5 · classify the page results · captcha · blocked · no results not results captcha 429 · blocked 403 · limit 429 no results → 200 with an empty list 6 · parse blocks → unwrap links → JSON rank, title, url, snippet, type, domain step 6 also writes the cache entry that step 1 reads on the next identical query
One request with a cold cache. Step 4 loads the results page; step 6 may call the engine again to follow wrapped result links.

1. Cache

Identical requests inside 120 seconds are answered from memory. The key is the engine, the query and every parameter (language, region, limit, page, filters). The second run of each query above returned in under 1 ms with took_ms: 0. Only non-empty JSON responses are cached, and a response served by a fallback engine is not.

2. Rate limiter and circuit breaker

Each engine has its own token bucket: 60 requests per minute, 3 allowed back to back. A circuit breaker per engine opens after 5 failures: for the next 60 seconds that engine fails immediately instead of holding a Chrome tab for up to 15 seconds per attempt, then 2 successful test requests close it again. Timeouts and network errors are retried once; parser failures are not. A captcha, block or 429 is not retried through the same IP; with a proxy pool of 2+ healthy proxies, it is retried once through the next proxy and the challenged one is moved to the back.

3. The engine URL

The query becomes the URL a person’s browser would send. For Google:

ParameterSent
q, oqalways: the text, plus site:x / filetype:y if asked
pws=0, sourceid=chrome, ie=UTF-8always: no personalisation, Chrome as the source
hl, lr, gl, uulewhen lang / region is set: language, country, encoded location
startfrom page 2 on: 10, 20 …
numonly when limit > 10
filter=0only when the caller passes filter=0: keep near-duplicates
hostgoogle.com, or the country domain for the locale: google.de, google.co.jp …

The example request sends only the first two rows to www.google.com. Every engine has its own builder; the parameter names differ, the idea does not.

4. Load the page in Chrome

The default mode drives headless Chromium through the Chrome DevTools Protocol (Go library go-rod). Per search:

5. Classify the page

A search engine that suspects a bot often does not answer with an error code. It answers 200 with a different page. So before parsing, the page is sorted:

PageRecognised byAPI response
Resultsthe result-block selector matches200 with rows
Captchacaptcha element or text; Google’s /sorry/ URL429 captcha_detected
Soft blocka “please enable JavaScript” retry page with no results403 blocked
No resultsthe engine’s own “nothing found” block200, empty list
Upstream 403 / 429status of the main document (raw mode also 401, 5xx)403 / 429

Without this step a captcha page parses as “0 results”, and the caller believes the web has nothing on the topic. A captcha solver (2captcha) can be switched on for Google; it is off by default.

6. Parse, unwrap, normalise

Each result block yields a title, a link and a snippet. Sponsored blocks carry their own markers; they come back with "type": "ad" instead of "organic", not dropped. Two things then have to be fixed.

Links are wrapped. Search engines route clicks through themselves to count them:

EngineWrapped linkWhat happens
Google/url?q=…, /goto?…destination decoded from the query string, or the redirect followed
Google ads/aclk?…, googleadservices.com/pagead/aclknever followed: that bills the advertiser
Bingbing.com/ck/a?…destination decoded
DuckDuckGoduckduckgo.com/y.js?…kept as-is; marks the row as an ad

When a Google /url or /goto link carries no readable destination, OpenSERP follows the redirect: 4 at a time, inside a 3-second budget per search. After a challenge on one exit IP, resolving through that IP pauses for 10 minutes.

Rows are normalised. Each row gets a stable ID, its absolute position across pages, the domain and a domain category. Rank 1 of the /mega/search response further down (from DuckDuckGo):

{
  "id": "s_6c5765c2969eaedd",
  "rank": 1,
  "type": "organic",
  "title": "TCP slow start - Glossary | MDN",
  "url": "https://developer.mozilla.org/en-US/docs/Glossary/TCP_slow_start",
  "display_url": "developer.mozilla.org › en-US › docs › Glossary › T…",
  "snippet": "Jul 16, 2025TCP slow start is an algorithm that helps discover the available network bandwidth …",
  "domain": "developer.mozilla.org",
  "position": { "absolute": 1 },
  "engine": "duckduckgo",
  "domain_info": { "tld": "org", "sld": "mozilla", "category": "" }
}

"Jul 16, 2025TCP" is verbatim: the page’s date and the snippet text arrive in one element, and the parser joins them without a space. Every SERP parser is a list of selectors written against markup the engine can change at any time; when Google renames a class, rows shrink or the request fails with 504 search_timeout. Watch row counts as well as status codes.

Why a browser at all: raw HTTP, measured

Parsing HTML does not need Chrome. OpenSERP also has a raw mode: one HTTP request per search, sent through tls-client, a Go HTTP client whose TLS handshake imitates Chrome’s, then parsed with goquery. No browser process, no tab, no JavaScript. Same four queries, both modes:

EngineBrowser modeRaw HTTP
Google4/4 · 7–10 rows · 3.65 s0/4 · blocked
Bing4/4 · 1–10 rows · 2.85 sno raw parser
DuckDuckGo4/4 · 10 rows · 1.50 sno raw parser
Yandex4/4 · 10 rows · 1.42 s0/4 · 302 redirect
Baidu4/4 · 8 rows · 2.18 s4/4 · 8–10 rows · 1.17 s

Times are the mean took_ms over 4 different queries, limit=10, cold cache.

Bar chart of mean search latency per engine over four queries. Browser mode: Google 3652 milliseconds, Bing 2848, Baidu 2183, DuckDuckGo 1496, Yandex 1421. Raw HTTP mode: Baidu 1174 milliseconds; Google was blocked on all four queries, the first after 163 milliseconds, and Yandex redirected away from the results on all four. A cache hit takes under one millisecond. Mean time per search, 4 queries each bar = mean · line under it = fastest to slowest · cold cache, limit=10, no proxy 0 s 1 s 2 s 3 s 4 s Google · browser 3.65 s Bing · browser 2.85 s Baidu · browser 2.18 s DuckDuckGo · browser 1.50 s Yandex · browser 1.42 s Baidu · raw HTTP 1.17 s Google · raw HTTP blocked 4 of 4 · first answer after 0.16 s Yandex · raw HTTP 302 away from results, 4 of 4
Browser mode answers every engine in 1.4–3.7 s. Raw HTTP is faster where it is let through (Baidu) and refused by Google and Yandex. The Google raw bar is a single timing, not a mean. A cache hit, not drawn, is under 1 ms.

OpenSERP classified Google’s raw-mode answer as 403 blocked on all four queries, the first after 163 ms. A TLS handshake that looks like Chrome was not enough. That is why browser mode is the default and the only mode every engine supports; raw mode is the faster option for engines that tolerate it.

The row counts also move. Bing returned 10, 3, 1 and 10 rows for four queries with limit=10, all HTTP 200. A SERP API returns what the engine showed that request, and limit is a ceiling, not a promise.

Several engines at once

/mega/search sends the query to several engines in parallel and merges them. For the TCP query across Google, Bing, DuckDuckGo and Yandex:

Wall time3.56 s, about the slowest single engine (Google, 3.55 s alone)
Rows returned37
Unique URLs after dedupe34
URLs returned by 2+ engines3
Engines failed0

Duplicates are matched on a normalised URL. Each unique URL becomes a cluster with a score:

score = sum(1 / rank for each engine that returned it) / engines queried

MDN’s “TCP slow start” page was rank 1 on DuckDuckGo and rank 4 on Yandex: (1/1 + 1/4) / 4 = 0.31, the top cluster. Only 3 of the 34 URLs came back from more than one engine, which is the argument for asking more than one. A slow engine does not hold the response forever: after 90 seconds the merge returns with whatever arrived and lists the missing engines in meta.engines_failed.

Reading the pages behind the results

A SERP gives a title and two lines of snippet. An LLM usually needs the page. OpenSERP’s /extract?url= fetches a URL and returns its main text, language, canonical URL and Open Graph tags. In auto mode it tries a plain HTTP fetch first and starts Chrome only when the text comes back shorter than a threshold, keeping whichever version holds more text. https://go.dev/doc/ came back in 0.30 s (49 KB, plain fetch, no browser needed). Extraction quality across tools is measured in Web scrapers for local LLMs.

What breaks

Self-hosted vs a hosted SERP API

Self-hosted (OpenSERP)Hosted scraper API (SerpApi, Serper …)
Price per search$0; your CPU and RAM, one Chrome tab per searchpriced per search (see vendor)
Blocks and captchasyour problem: proxies, pacingthe vendor’s
Parser upkeepupdate the release when engines change markupthe vendor’s
Engines6; one query can hit severalper vendor
Who sees your queriesthe search enginethe search engine and the vendor

An engine’s own API, such as Brave Search’s, is a third option: no scraping at all, results from that engine’s index only. How search results compare in quality is in Web search quality for local LLMs; what a search response costs in tokens is in Output formats & token cost.

Questions? Let's Chat :)

ai llm engineering