Originally published on medium.com

Getting blocked while web scraping? 21 practical techniques with Python code to scrape without getting blocked: rotating proxies, headers, TLS fingerprints, anti-bot tips.
Your scraper doesn’t fail because of your code.
It fails because it screams “I’m a bot” on every single request.
If you want to avoid getting blocked while web scraping, you need to understand one thing first: modern websites don’t block scrapers, they block signals. A missing header. An IP from a datacenter. A request every 200 milliseconds, perfectly timed, 24 hours a day.
If you’re:
this guide is for you.
Below are 21 techniques I use for web scraping without getting blocked, grouped into five blocks, each one with a short Python example you can copy.
mode=auto escalates only when needed, and the free plan includes 5,000 credits/month.Anti-bot systems like Cloudflare Bot Management, DataDome, Akamai or PerimeterX score every request. When the score crosses a threshold, you get a 403 Forbidden, a CAPTCHA, or worse: a 200 response with fake or empty data.
They look at four things:

Fix one and ignore the others, and you still get blocked.
That’s the real problem. Blocking is multi-layered, so your strategy to avoid getting blocked has to be too.
The default python-requests/2.x User-Agent is the fastest way to get banned. Use real browser strings and rotate them.
import random
import requests
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0",
]
headers = {"User-Agent": random.choice(USER_AGENTS)}
r = requests.get("https://httpbin.io/headers", headers=headers)
print(r.json())
Keep the list updated. A Chrome version from three years ago is almost as suspicious as no User-Agent at all. 📖 MDN: User-Agent header
A real browser never sends only a User-Agent. It sends Accept, Accept-Language, Accept-Encoding, Sec-Fetch-* and more.
headers = {
"User-Agent": USER_AGENTS[0],
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
}
Tip: open httpbin.io/headers in your browser, then call it from your script. Compare both outputs. Every difference is a signal.
If your User-Agent says Firefox but you send Chrome-only headers like sec-ch-ua, you've just flagged yourself. Every header set should match the browser it claims to be.
A simple rule: build header profiles (one per browser) and rotate the full profile, never individual headers. 📖 MDN: Sec-CH-UA client hints
Real users arrive from somewhere. Usually Google, or another page of the same site.
headers["Referer"] = "https://www.google.com/"
For deep pages, set the Referer to the category or listing page that links to them.
Many sites set cookies on the first visit and expect them back. A scraper that never returns cookies looks like a thousand brand-new visitors.
session = requests.Session()
session.headers.update(headers)
session.get("https://example.com/") # gets cookies
r = session.get("https://example.com/products") # sends them back
Headers alone won’t help you avoid getting blocked, though.
If every request comes from the same IP, nothing else matters.
Sending thousands of requests from one IP is the oldest red flag in web scraping. Use a proxy pool and rotate IPs.
import itertools
PROXIES = [
"http://user:pass@proxy1.example.com:8000",
"http://user:pass@proxy2.example.com:8000",
"http://user:pass@proxy3.example.com:8000",
]
proxy_pool = itertools.cycle(PROXIES)
for url in urls:
proxy = next(proxy_pool)
r = requests.get(url, headers=headers, proxies={"http": proxy, "https": proxy}, timeout=30)
Datacenter IPs are cheap and fast, and anti-bot systems know every one of their ranges. Residential proxies use IPs from real ISPs, so they carry much higher trust.
Use datacenter proxies for easy targets. Switch to residential proxies when you start seeing 403 Forbidden errors.
Scraping a Spanish e-commerce site from a Singapore IP looks odd. It can also return different prices, currencies or content. Pick proxies from the country your target expects.
Rotating proxies are great until you need to log in, paginate or keep a cart. Changing IP mid-session is a classic bot signal. Keep the same IP for the whole flow, then rotate.
Managing a residential proxy pool, geo-targeting and sticky sessions yourself is a real project.
This is where most people quietly give up.
Skip the proxy plumbing
ZenRows gives you rotating residential proxies (premium_proxy), country targeting (proxy_country) and sticky sessions (session_id) as simple API parameters.
→ Try ZenRows for free
Hammering a server is both rude and obvious. Limit concurrency and requests per second per domain.
import time
for url in urls:
r = session.get(url)
time.sleep(1.5)
Humans are irregular. Bots are perfectly regular. A fixed sleep(1) is a fingerprint in itself.
time.sleep(random.uniform(1.2, 4.5))
When the server says “slow down”, listen. Retrying instantly just confirms you’re a bot and gets your IP blocked faster.
def fetch_with_backoff(url, max_retries=5):
for attempt in range(max_retries):
r = session.get(url, timeout=30)
if r.status_code not in (429, 503):
return r
wait = int(r.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait + random.random())
return r
Checking robots.txt keeps you away from the traps (honeypot URLs) and the areas the site watches most closely. It's also the ethical baseline for any web scraping project.
from urllib.robotparser import RobotFileParser
rp = RobotFileParser("https://example.com/robots.txt")
rp.read()
print(rp.can_fetch("*", "https://example.com/products"))
print(rp.crawl_delay("*"))
Your traffic stands out less when it’s spread across the day, and you put less load on the site at night in its local time zone. Big batch jobs belong there.
Behavior is where most DIY scrapers improve the fastest.
But the hardest anti-bot layer is still ahead.
Plain headless Chrome leaks signals like navigator.webdriver = true. Use stealth patches to hide them.
# pip install playwright playwright-stealth
# playwright install chromium
from playwright.sync_api import sync_playwright
from playwright_stealth import Stealth
with Stealth().use_sync(sync_playwright()) as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://example.com")
print(page.title())
browser.close()
This one surprises people. Even with perfect headers, the TLS handshake from Python’s requests doesn't look like Chrome's. Anti-bot systems check it (JA3/JA4 fingerprints).
curl_cffi impersonates a real browser at the TLS level:
# pip install curl_cffi
from curl_cffi import requests as cffi_requests
r = cffi_requests.get("https://example.com", impersonate="chrome")
print(r.status_code)
Many sites load data with JavaScript, and some anti-bot challenges require JS execution to pass. But a headless browser is slow and expensive. Use it for the pages that need it, not for everything.
If you’re seeing CAPTCHAs, the anti-bot system already distrusts you. Solving services work, but they’re slow and cost money per solve. Fixing the signals from techniques 1–17 is cheaper than solving the CAPTCHA they trigger.
Stealth browsers and TLS impersonation work.
Until the anti-bot vendor ships an update and you’re back to debugging on a Friday night.
Open DevTools → Network → Fetch/XHR and reload the page. Many sites load their data from a JSON endpoint. Calling it directly is faster, cleaner and lighter than parsing HTML.
r = session.get("https://example.com/api/products?page=1")
data = r.json()
The request you don’t send can’t get you blocked. Store responses, skip URLs you’ve already scraped and only refresh what changed.
# pip install requests-cache
import requests_cache
session = requests_cache.CachedSession("scrape_cache", expire_after=3600)
Techniques 1 to 18 are a full-time job: rotating proxies, header profiles, TLS impersonation, headless browsers, CAPTCHA handling, retries. And all of it breaks whenever the anti-bot vendors change their rules.
A web scraping API bundles all of that behind one request.
After testing several options, ZenRows is the one I reach for when a site fights back. Here’s why:
**mode=auto**) starts with the cheapest setup and escalates to JS rendering or premium proxies only when needed. You're billed only for the configuration that succeeds. Docspremium_proxy=true and proxy_country (e.g. es, us, de). Docsjs_render=true) plus wait_for and js_instructions for dynamic pages.session_id (any number from 1 to 99999), which keeps the same IP for 10 minutes. DocsSec-Ch-Ua, Sec-Fetch-* and similar headers for you, so they always match the fingerprint.response_type=markdown) for LLM pipelines.That single technique covers most of the other 20.

Let’s use ZenRows’ own test page, scrapingcourse.com/antibot-challenge, which is protected by an anti-bot challenge. First, the DIY version:
import requests
url = "https://www.scrapingcourse.com/antibot-challenge"
r = requests.get(url, headers={"User-Agent": USER_AGENTS[0]}, timeout=30)
print("Status:", r.status_code)
print("Bypassed:", "You bypassed the Antibot challenge" in r.text)
You’ll get the challenge page instead of the content:
Bypassed: False
Now with ZenRows and Adaptive Stealth Mode:
# pip install requests
import requests
params = {
"url": "https://www.scrapingcourse.com/antibot-challenge",
"apikey": "YOUR_ZENROWS_API_KEY",
"mode": "auto",
}
r = requests.get("https://api.zenrows.com/v1/", params=params, timeout=180)
print("Bypassed:", "You bypassed the Antibot challenge" in r.text)
print("Cost (USD):", r.headers.get("X-Request-Cost"))
Bypassed: True
Cost (USD): 0.025
No proxy list. No header profiles. No stealth patches.
The X-Request-Cost header shows what the request cost (0.001 USD = 1 credit), so you can see when the API had to escalate. Two details from the API reference: use a client timeout of 180 seconds, because protected pages can take a while, and don't send js_render or premium_proxy together with mode=auto. Auto mode manages those two for you.
Need control instead of automation? Set the parameters yourself:
params = {
"url": "https://www.scrapingcourse.com/ecommerce/",
"apikey": "YOUR_ZENROWS_API_KEY",
"js_render": "true",
"premium_proxy": "true",
"proxy_country": "es",
"wait_for": ".product",
"css_extractor": '{"names": ".product-name", "prices": ".price", "links": "a.woocommerce-LoopProduct-link @href"}',
}
r = requests.get("https://api.zenrows.com/v1/", params=params, timeout=180)
print(r.json())
A few rules worth knowing:
wait_for (and wait) only work with js_render=true.proxy_country requires premium_proxy=true.css_extractor returns JSON: one match comes back as a string, several as a list. Add @href or @src to grab attributes. DocsZenRows manages browser headers, but you can still send your own Referer or Cookie. Turn on custom_headers and pass them with the normal headers= argument:
params = {
"url": "https://httpbin.io/anything",
"apikey": "YOUR_ZENROWS_API_KEY",
"custom_headers": "true",
}
headers = {"Referer": "https://www.google.com/"}
r = requests.get("https://api.zenrows.com/v1/", params=params, headers=headers, timeout=180)
print(r.text)
Headers like User-Agent or Sec-Fetch-* are ignored here on purpose, so they never contradict the browser fingerprint. Docs
# pip install zenrows
from zenrows import ZenRowsClient
client = ZenRowsClient("YOUR_ZENROWS_API_KEY")
response = client.get("https://www.scrapingcourse.com/ecommerce/", params={
"js_render": True,
"premium_proxy": True,
"proxy_country": "us",
})
print(response.text)
From here you can build:

❓ How do I avoid getting blocked while web scraping?
✅ Combine several techniques: real browser headers, rotating residential proxies, random delays with backoff on 429 errors, and a TLS fingerprint that matches a real browser. If the site uses serious anti-bot protection, a web scraping API like ZenRows handles those layers for you.
❓ Why do I get a 403 Forbidden error when web scraping?
✅ A 403 usually means the anti-bot system flagged your request. The most common causes are a default Python User-Agent, missing browser headers, a datacenter IP or a non-browser TLS fingerprint. Fix headers first, then move to residential proxies.
❓ Is web scraping legal?
✅ Scraping publicly available data is generally legal in many jurisdictions, but it depends on the data, the site’s terms and local laws (personal data under GDPR, for example). Avoid scraping behind logins without permission, respect robots.txt and check with a lawyer for commercial projects.
❓ How many requests per second can I send without getting blocked?
✅ There’s no universal number. Start around one request every 1–3 seconds per domain with random jitter, watch for 429 responses and adjust. Rotating proxies let you spread volume, but they don’t make aggressive scraping invisible.
❓ Do I need residential proxies for web scraping?
✅ Not always. Datacenter proxies work fine for many sites. You need residential proxies when the target uses serious anti-bot protection (Cloudflare, DataDome, Akamai) or blocks datacenter IP ranges.
❓ Is there a free web scraping API?
✅ ZenRows has a free plan with 5,000 credits per month and no credit card required. A standard request costs 1 credit, JS rendering 5, premium proxies 10, and both together 25. See pricing.
Avoiding blocks isn’t about one clever trick.
It’s about not giving the anti-bot system a single reason to doubt you. You can build that yourself, layer by layer, or hand the hardest layers to a web scraping API and focus on the data.
Stop fighting anti-bot systems
Rotating residential proxies, JS rendering and anti-bot bypass in one request. 5,000 free credits per month, no card required.
→ Start scraping with ZenRowsMore practical guides on Python, APIs and automation
Tutorials, tools and real workflows for developers.
→ Visit kevinmeneses.com
Looking for technical content for your company? I can help · LinkedIn · kevinmenesesgonzalez@gmail.com