Why Your Scraper Returns an Empty Page (and How to Fix It)
Scraper returns empty page with a 200? A 5-step triage separates JS rendering, disguised block pages and geo or consent gates, with a fix and code for each.
When a scraper returns an empty page, the script almost never crashes. You get 200 OK, soup.select(".price") comes back as [], and the HTML you print is either a near-empty or a page full of markup that contains none of the numbers you can see in your browser. Three different causes produce that identical symptom, and each one needs a different fix, so guessing wastes days. This guide gives you a five-step triage, cheapest check first, that tells you which cause you have in a few minutes, then the fix for each.
The symptom, and the three causes behind it
Here is the script that brought most readers to this page:
import requests
from bs4 import BeautifulSoup
r = requests.get("https://shop.sparkproxy.io/products/widget-42", timeout=30)
print(r.status_code) # 200
soup = BeautifulSoup(r.text, "html.parser")
print(soup.select("span.price")) # []
print(soup.get_text(strip=True)[:200]) # "Loading..." or a cookie notice or nothing
Status 200 only says the server answered. It says nothing about which document it chose to send you. In practice the blank result comes from one of three places:
| Cause | What the server sent | One-line test | Fix |
|---|---|---|---|
| 1. JavaScript-rendered content | The real page shell; data arrives later via XHR | The visible value is absent from raw HTML but present after rendering | Render (headless browser or `render_js`), or call the JSON endpoint |
| 2. Disguised block page | A challenge or "access denied" document with status 200 | Title, `cf-mitigated` header or a tiny body gives it away | Change IP class, pacing or fingerprint |
| 3. Geo or consent interstitial | A cookie wall, region selector or a different country's site | Final URL, `` or currency differs from your browser | Send a locale, pick an exit country, carry consent cookies |
These overlap more than people expect. A React storefront (cause 1) can also be geo-gated (cause 3), and rendering it from a flagged IP adds cause 2 on top. That is why the order of checks matters: each step rules something out with a single request before you spend money on the next.
Step 1: Save the raw bytes and measure them
Do not debug from print(soup). BeautifulSoup repairs broken markup, your terminal truncates, and you end up reasoning about a document nobody sent. Write the exact bytes to disk and take four measurements:
import re
import requests
def snapshot(url, **kw):
r = requests.get(url, timeout=30, **kw)
with open("raw.html", "wb") as fh:
fh.write(r.content)
html = r.text
visible = re.sub(r"<script.*?</script>|<style.*?</style>|<[^>]+>", " ", html, flags=re.S)
return {
"status": r.status_code,
"bytes": len(r.content),
"script_tags": len(re.findall(r"<script\b", html)),
"visible_words": len(visible.split()),
"final_url": r.url,
}
print(snapshot("https://shop.sparkproxy.io/products/widget-42"))
Read the numbers as a shape, not as thresholds:
- Large body, many script tags, very few visible words points at cause 1. A client-rendered shell is mostly JavaScript bundles and a mount node.
- Small body, few scripts, a handful of words points at cause 2. Challenge and denial pages are short, single-purpose documents.
- Normal size and plenty of words, just not your words points at cause 3, or at a page that was legitimately redesigned.
Open raw.html in a browser with JavaScript disabled as well. You will see exactly what your parser saw, and half the time the answer is printed on screen: a spinner, a "verify you are human" box, or a language picker.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Step 2: Grep the raw HTML for one value you can see
Pick one concrete string from the page in your real browser: a price like 49.99, a SKU, a review author. Then search the saved bytes for it. This is the single most decisive test in the triage, and it costs nothing.
needle = "49.99"
raw = open("raw.html", encoding="utf-8", errors="replace").read()
print("in raw HTML:", needle in raw)
for m in re.finditer(re.escape(needle), raw):
print(raw[max(0, m.start() - 80): m.end() + 40].replace("\n", " "))
Three outcomes:
- Found inside normal markup. Your selector is wrong, not the fetch. Check the class name in
raw.htmlrather than in DevTools, because DevTools shows the live DOM after scripts ran. - Found inside a
tag (often a JSON blob assigned to a variable). The data shipped with the page but is drawn by JavaScript. Parse that JSON directly; the single-page application guide covers hydration payloads in depth. - Not found anywhere. Either it is fetched later by the browser (cause 1), or you were sent a different document altogether (causes 2 and 3). Steps 3 and 4 separate those.
Step 3: Read the headers, final URL and title
Block pages hide behind a 200 deliberately, but they rarely hide everywhere at once. The response headers, the redirect chain and the are three cheap places they leak.
BLOCK_MARKERS = (
"access denied", "verify you are human", "unusual traffic",
"are you a robot", "request unsuccessful", "attention required",
"enable javascript and cookies", "pardon our interruption",
)
def block_signals(r):
html = r.text.lower()
title = re.search(r"<title[^>]*>(.*?)</title>", html, re.S)
return {
"cf_mitigated": r.headers.get("cf-mitigated"),
"redirects": [h.status_code for h in r.history],
"final_url": r.url,
"title": title.group(1).strip()[:80] if title else None,
"markers": [m for m in BLOCK_MARKERS if m in html],
}
The strongest signal here is vendor-documented. Cloudflare states in its challenge-detection documentation that a challenge response has the cf-mitigated header "present and set to challenge", and that "challenge is the only valid value". If you see that header, stop debugging selectors: you have cause 2.
Other vendors are less explicit, so the marker list does the work. Treat it as a starting set you grow from your own logs, not a complete catalogue. If you already run monitoring, the sibling guide on detecting when your scraper is blocked builds this into baselines and canaries; here we only need a yes or no for one URL.
A redirect chain that ends somewhere unexpected (r.history is non-empty and r.url is a /consent, /region, /challenge or locale path) belongs to Step 4.
Step 4: Compare what the site saw against what a browser sends
This is the cause people miss, because nothing looks broken. The page renders, has a title, has hundreds of words. They are just the wrong words: a cookie wall, a "choose your country" selector, or the German storefront in euros.
Start with what requests actually sends. Its default_headers() function in the requests source returns exactly four headers: User-Agent (python-requests/), Accept-Encoding, Accept: / and Connection: keep-alive. There is no Accept-Language and, on a fresh session, no cookies. MDN defines Accept-Language as the header that "indicates the natural language and locale that the client prefers." A site that keys its locale on that header, or on a consent cookie it expects every returning browser to carry, has nothing to go on, so it falls back to IP geolocation and a first-visit interstitial.
The test is one request with the two things a browser always has:
browser_like = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
bare = requests.get(URL, timeout=30)
dressed = requests.get(URL, headers=browser_like, timeout=30)
def lang(html):
m = re.search(r"<html[^>]*\blang=[\"']?([\w-]+)", html, re.I)
return m.group(1) if m else None
for name, resp in (("bare", bare), ("dressed", dressed)):
print(name, resp.url, lang(resp.text), "49.99" in resp.text)
If the dressed request lands on a different final URL, flips , or suddenly contains your price, you have cause 3, and the fix is cheap. If both return the same locale but a different country than you expected, your exit IP is geolocated somewhere else; that also belongs to cause 3, and the fix is choosing the exit country. The geo-targeting explainer covers how providers resolve that.
Step 5: Fetch it rendered and diff the two
If Steps 2 to 4 have not produced an answer, render the page once and compare. This is the most expensive check, which is why it is last. With the SparkProxy Scraping API you can do it without installing a browser. render_js defaults to true and costs 5 credits; render_js=false is a plain fetch at 1 credit. Requesting json_response=true wraps the result in an envelope (job_id, status_code, duration_ms, credits_used, body), and the docs state the body is base64-encoded, so decode it before you search it.
import base64
import requests
API = "https://scrape.sparkproxy.io/api/v1"
HEAD = {"X-API-Key": "YOUR_API_KEY"}
def fetch(url, render):
r = requests.get(API, headers=HEAD, timeout=200, params={
"url": url,
"render_js": "true" if render else "false",
"json_response": "true",
})
r.raise_for_status()
env = r.json()
html = base64.b64decode(env["body"]).decode("utf-8", "replace")
return env["status_code"], env["credits_used"], html
plain_status, plain_cost, plain = fetch(URL, render=False)
rend_status, rend_cost, rendered = fetch(URL, render=True)
print("plain:", plain_status, plain_cost, len(plain), "49.99" in plain)
print("rendered:", rend_status, rend_cost, len(rendered), "49.99" in rendered)
Read the 2x2:
| Plain fetch has value | Rendered fetch has value | Diagnosis |
|---|---|---|
| No | Yes | Cause 1. Content is client-rendered. |
| No | No, and rendered page is a challenge | Cause 2. The block holds even with a browser. |
| No | No, and rendered page is a cookie wall or other locale | Cause 3. Rendering does not help; locale and country do. |
| Yes | Yes | Your own code was the problem (selector, encoding, parser). |
Watch for one trap in that table: a renderer that clears a JavaScript challenge can make cause 2 look like cause 1. If Step 3 already flagged block markers, trust Step 3.
Fix for cause 1: render the JavaScript
You have two real options, and the cheaper one is not a browser. If Step 2 found the value inside a blob, or the browser's Network tab shows a JSON request carrying the data, call that endpoint directly; the hidden JSON API guide walks through finding and replaying it. Only render when the data is computed in the browser or the endpoint is signed.
When you do render, the most common reason a rendered fetch is still empty is that you read the DOM before the data arrived. Wait for the element you need, not for a fixed delay:
r = requests.get(API, headers=HEAD, timeout=200, params={
"url": URL,
"render_js": "true", # default, 5 credits
"wait_for": "span.price", # holds up to 30 s for the selector
"block_resources": "true", # skip images, fonts, CSS
})
soup = BeautifulSoup(r.text, "html.parser")
print([s.get_text(strip=True) for s in soup.select("span.price")])
The same idea in Playwright for Python (version 1.64.1 on PyPI, uploaded 2026-10-22) is page.wait_for_selector("span.price") before page.content(). Rendering strategy, infinite scroll and click-driven content are covered properly in how to scrape dynamic JavaScript websites, so this post stops at "wait for the right selector."
Fix for cause 2: change the IP class, not just the IP
A disguised block page has a reason, and the reason decides the fix. Work out which of these you are hitting before you buy anything:
| Block trigger | How it shows up | What fixes it |
|---|---|---|
| Per-IP rate limit | Starts after N requests, clears after a pause | Rotate IPs, slow down |
| IP reputation on one address or subnet | Some exits fine, some always blocked | Rotate across a larger pool |
| ASN or IP-class policy | Every datacenter exit blocked, home connection fine | A different IP class |
| Client fingerprint (TLS, headers, headless) | Blocked from every IP, including clean ones | Fix the client, not the IP |
The first two are what rotating proxies are for. SparkProxy's plans are datacenter proxies (the homepage, read 2026-10-30, describes "Over a million premium datacenter IPs" across 80+ countries) with Random Rotation, which hands every request a new exit, or Sticky Sessions, which hold one IP for five minutes when a flow needs continuity:
PROXY = "http://USERNAME:PASSWORD@gateway.sparkproxy.io:11000" # Random Rotation
r = requests.get(URL, headers=browser_like, timeout=30,
proxies={"http": PROXY, "https": PROXY})
print(block_signals(r))
Pricing is "Starting from $75/month" (USD, read from sparkproxy.io on 2026-10-30), sold as plan packs with unlimited bandwidth rather than per GB, so retries against a stubborn target cost concurrency, not traffic.
The third row is the honest limit. If the target refuses datacenter address space as a class, rotating through a million more datacenter IPs returns the same block page a million times. Test it in one line: fetch through the Scraping API with proxy_type=premium, which routes through a separate premium pool. The docs list that at 10 credits without rendering and 25 with it (read 2026-10-30), so use it for the targets that need it rather than as a default. If premium returns the real page and your datacenter exit does not, you have an IP-class block. If both return the challenge, you are in row four, and the headless browser detection guide is the next stop.
Fix for cause 3: set locale, country and consent state
Geo and consent gates are the cheapest cause to fix, because the site is not trying to stop you. It just needs to know who you claim to be.
Three levers, in order of cost:
- Send
Accept-Languagematching the market you want. This alone fixes most language-picker and wrong-locale pages. - Choose the exit country. In the Scraping API,
country_codetakes an ISO alpha-2 code and adds 5 credits. A German exit withAccept-Language: en-USis a contradiction some sites resolve in favour of the IP, so keep the two consistent. - Carry the consent state. Accept the banner once in a real browser, find the cookie it set (DevTools, Application, Cookies), and send it. Cookie names vary per site, so copy the real one rather than guessing.
import json
r = requests.get(API, headers=HEAD, timeout=200, params={
"url": URL,
"render_js": "true",
"country_code": "US",
"forward_headers": json.dumps({"Accept-Language": "en-US,en;q=0.9"}),
"cookies": json.dumps([{"name": "CONSENT_COOKIE_NAME", "value": "accepted",
"domain": ".shop.sparkproxy.io"}]),
"wait_for": "span.price",
})
The docs describe cookies as requiring render_js=true, and forward_headers as a JSON object; they currently describe it both as merged with browser defaults and as sent alone, so check the echoed request in your logs if a header seems ignored. In Playwright the equivalent is browser.new_context(locale="en-US", timezone_id="America/New_York") plus context.add_cookies([...]). For sessions that must survive several requests, handling cookies and sessions has the patterns.
What a proxy can and cannot fix
People reach for proxies first because blank pages feel like blocking. Often they are not.
| Cause | Can a proxy fix it? | Why |
|---|---|---|
| JS-rendered content | No | The data is not in the HTML from any IP. You need rendering or the JSON endpoint. |
| Per-IP rate limit or flagged IP | Yes | Rotation spreads requests and replaces burned exits. |
| Datacenter-class block | Only a different IP class | More of the same class gets the same answer. |
| Fingerprint block | No | The client is recognised regardless of address. |
| Wrong country storefront | Yes | Pick an exit in the right country. |
| Language picker or consent wall | Rarely | Headers and cookies fix it; the IP is incidental. |
Two of six rows are a clean yes. That ratio is the main reason the triage runs before any spend.
The whole triage in one script
Everything above, collapsed into one function that returns the branch to take. It uses only plain fetches, so it costs one free request per URL, and you escalate to Step 5 only when it returns unknown.
import re
import requests
def triage(url, needle, headers):
bare = requests.get(url, timeout=30)
dressed = requests.get(url, headers=headers, timeout=30)
sig = block_signals(bare)
if needle in bare.text:
return "selector: value is in the raw HTML, fix your parser"
if sig["cf_mitigated"] == "challenge" or sig["markers"]:
return "cause 2: block page, change IP class or fingerprint"
if needle in dressed.text or dressed.url != bare.url or lang(dressed.text) != lang(bare.text):
return "cause 3: geo or consent gate, set locale, country, cookies"
if len(re.findall(r"<script\b", bare.text)) > 10 and len(bare.text) > 20000:
return "cause 1 (likely): client-rendered, confirm with a rendered fetch"
return "unknown: run the rendered diff in Step 5"
print(triage(URL, "49.99", browser_like))
The script-count and size cut-offs in the cause 1 branch are rough heuristics, not measured thresholds, which is why that branch says "likely" and points at Step 5. Tune them on your own targets. The ordering is the part that matters: parser first, block second, locale third, rendering last, because that runs from the free checks to the paid one.
Frequently asked questions
FAQ
Because 200 only means the server answered, not that it sent the page you see in a browser. The body is usually a JavaScript shell that fills in later, a block page served with a 200, or a geo or consent interstitial. Grep the raw HTML for a visible value to tell them apart.
BeautifulSoup only parses the HTML you downloaded, and DevTools shows the DOM after JavaScript ran. If the value is missing from the saved raw bytes, the data is loaded by script or you received a different document, so no selector change will help.
Check for a cf-mitigated: challenge header, a short body with few words, a title like "Access Denied", and phrases such as "verify you are human". Any one of those means you have a block page, so changing selectors will not help.
Only for some causes. Rotating proxies fix per-IP rate limits and flagged addresses, and an exit in the right country fixes wrong-storefront pages, but no proxy fixes JavaScript-rendered content or a fingerprint block.
The requests library sends no Accept-Language header and no cookies on a fresh session, so the site falls back to IP geolocation and a first-visit consent screen. Send a matching Accept-Language, choose the exit country, and carry the consent cookie your browser stores.
Only if Step 5 shows the value appears after rendering and you cannot find the JSON endpoint behind it. Many client-rendered sites ship the data in a script tag or a public XHR call, which is cheaper and faster to scrape than rendering every page.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles
Scrapy: Could Not Open CONNECT Tunnel With Proxy (Fixed)
Scrapy could not open CONNECT tunnel with proxy? Read the status inside the TunnelError, then fix 407s, dropped Proxy-Authorization, timeouts and concurrency.
Proxychains Configuration: Force Any Linux Tool via Proxy
Proxychains configuration for proxychains-ng 4.17: strict vs dynamic chain, proxy_dns, auth lines, Go and static binary workarounds, and a DNS leak test.
Proxy Showing Wrong Country? Align Every Geo Signal
Proxy showing wrong country content even though the IP checks out? Test cookies, Accept-Language, timezone, CDN edge and geo-IP lag in order, then fix each one.
