To test a proxy provider, send the same requests to the same targets through each provider in the same time window, then compare success rate, median and p95 latency, unique exit IPs, the status-code breakdown and bytes used. Turn bytes and success rate into a cost per successful request, because that is the number your budget pays. The Python script in this guide does all of it from one environment variable, and writes a results file per provider so you can compare them side by side.
Vendor benchmarks, ours included, are measured on someone else's targets. Your targets are the only ones that matter, and testing them takes an afternoon.
What to measure when you test proxies
| Metric | What it tells you | How to read it |
|---|---|---|
| Success rate | Share of requests that returned the page you wanted | Count only a 200 whose body contains a marker you expect. A 200 with a CAPTCHA is a failure |
| Status-code breakdown | Why the failures failed | 403 and 429 are the target pushing back; 407 is your credentials; timeouts and proxy errors are the network |
| Median latency | The typical request | Time to the last byte of the body, not to the first header |
| p95 latency | The slow tail your job waits on | A p95 many times the median means an uneven pool or a congested route |
| Unique exit IPs | How much of a rotating pool you are seeing | Many requests on few IPs is a thin pool; on a static IP the answer should be exactly 1 |
| Bytes used | What the meter will charge | Compare with the provider's own usage counter before and after the run |
| Cost per 1,000 successes | The price of a result | Effective price per GB times GB used, divided by successful requests |
An average latency hides the tail, and the tail is what your job waits on when it runs at concurrency. Report median and p95, and keep the raw timings so you can look at the distribution when two providers disagree.
Pick targets that look like your job
A test on a target you will never scrape measures nothing you need. Build the target list from three kinds of URL:
- Your real targets. Three to five URLs that represent the job: a product page, a search results page, a category listing. Pick pages you are allowed to collect, and check our allowed-use policy and the site's own terms first. This is not legal advice; it is the order to do things in.
- A control. An easy page that any working proxy should fetch every time, such as
https://example.com. If the control fails, the problem is the proxy or your setup, not the target. - An IP echo. A service that returns the caller's IP, such as
https://api.ipify.org?format=json. The script uses it to count unique exit IPs.
For each real target, choose a marker: a string that appears on the real page and not on a block page, such as a product title element or a price label. Many sites answer a blocked request with a 200 and a challenge page. Without a marker, those count as successes and your test flatters the worst provider.
Keep the load polite. Test at a concurrency and rate the target can absorb, never against login or checkout endpoints, and stop if a site starts answering with errors across the board. A benchmark that degrades a site is the thing our policy calls a denial of service, whatever the intent.
Sample size: how many requests are enough
Success rates are proportions, and small samples are noisy. The margin of error at a 95% confidence level is roughly 1.96 x sqrt(p x (1 - p) / n):
| Requests per target | Margin at a 90% success rate | What it can tell you |
|---|---|---|
| 100 | about 5.9 points | Whether the proxy works at all |
| 200 | about 4.2 points | Large differences between providers |
| 500 | about 2.6 points | 90% against 85%, with confidence |
| 1,000 | about 1.9 points | Small differences, and a stable p95 |
A p95 latency needs enough successes to have a tail: with fewer than about 200 successful requests, the 95th percentile rests on a handful of points. And one run is one moment. Run the same test at three different times of day, because pools, targets and routes all behave differently at 3 a.m. and at the start of the US working day.
Keep the test fair
Most proxy comparisons that go wrong do so because the two runs were not measuring the same thing. For a fair test:
- Same targets, same markers, same time window. Run providers at the same time from separate terminals, or alternate short runs, so a target's bad hour hits everyone.
- Same client, headers, concurrency and timeout. The script fixes these; do not tune them per provider.
- Same machine and network. Your own uplink is part of every latency figure.
- Like for like. Compare residential with residential and static with static. A datacenter IP will beat a residential pool on speed and lose on many protected targets; that is not news.
- Say what differs. If one vendor lets you target a country and another does not (our residential pool has no location selection), either test both untargeted or note the difference next to the result.
- Fresh connections. The script turns off connection reuse. A reused tunnel keeps the same exit IP and hides both rotation and connection setup time.
Write down the date, the time window, the plan you tested on and the exact target list. A benchmark nobody can repeat is an anecdote.
The proxy benchmark script
The script needs Python 3.10 or later and httpx 0.28 (pip install httpx). For socks5:// proxy URLs, install the extra with pip install "httpx[socks]". Note that httpx 0.28 removed the old proxies= argument; the script uses proxy=.
Save the target list as targets.txt, one URL per line, with an optional marker after |:
https://example.com | Example Domain
https://www.example-shop.test/product/123 | Add to basket
https://www.example-shop.test/search?q=kettle | results for
Replace the two shop lines with your own targets. Then save this as proxy_bench.py:
"""Benchmark one proxy provider: success rate, latency, exit IPs, status codes, bytes.
Usage:
export PROXY_URL="http://USERNAME:PASSWORD@HOST:PORT"
export PROXY_LABEL="vendor-a" # names the results file
python proxy_bench.py targets.txt
targets.txt holds one target per line: a URL, optionally followed by " | " and a
string the page must contain to count as a success (catches block pages served as 200).
"""
import asyncio
import json
import os
import statistics
import sys
import time
from collections import Counter
from dataclasses import asdict, dataclass
import httpx
PROXY_URL = os.environ["PROXY_URL"]
LABEL = os.environ.get("PROXY_LABEL", "proxy")
REQUESTS_PER_TARGET = int(os.environ.get("REQUESTS_PER_TARGET", "100"))
CONCURRENCY = int(os.environ.get("CONCURRENCY", "10"))
TIMEOUT = float(os.environ.get("TIMEOUT", "30"))
PRICE_PER_GB = float(os.environ.get("PRICE_PER_GB", "0"))
ECHO_URL = os.environ.get("ECHO_URL", "https://api.ipify.org?format=json")
ECHO_SAMPLES = int(os.environ.get("ECHO_SAMPLES", "50"))
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/128.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
@dataclass
class Result:
target: str
outcome: str
ok: bool
seconds: float
bytes: int
exit_ip: str | None = None
def load_targets(path: str) -> list[tuple[str, str | None]]:
targets = []
with open(path, encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line or line.startswith("#"):
continue
url, _, marker = line.partition(" | ")
targets.append((url.strip(), marker.strip() or None))
return targets
def exit_ip_of(response: httpx.Response) -> str:
try:
body = response.json()
return str(body.get("ip") or body.get("origin"))
except ValueError:
return response.text.strip()
def header_bytes(headers: httpx.Headers) -> int:
return sum(len(k) + len(v) + 4 for k, v in headers.raw)
async def fetch(client, semaphore, url, marker, is_echo=False) -> Result:
async with semaphore:
started = time.perf_counter()
try:
response = await client.get(url)
elapsed = time.perf_counter() - started
size = response.num_bytes_downloaded + header_bytes(response.headers)
outcome = str(response.status_code)
if response.status_code == 200 and marker and marker not in response.text:
outcome = "200-marker-missing"
ok = outcome == "200"
exit_ip = exit_ip_of(response) if is_echo and ok else None
return Result(url, outcome, ok, elapsed, size, exit_ip)
except httpx.HTTPError as error:
return Result(url, type(error).__name__, False, time.perf_counter() - started, 0)
def percentile(values: list[float], pct: int) -> float:
if len(values) < 2:
return values[0] if values else float("nan")
return statistics.quantiles(values, n=100, method="inclusive")[pct - 1]
def summarise(name: str, results: list[Result]) -> dict:
wins = [r for r in results if r.ok]
latencies = [r.seconds for r in wins]
total_bytes = sum(r.bytes for r in results)
summary = {
"target": name,
"requests": len(results),
"successes": len(wins),
"success_rate": round(len(wins) / len(results), 4) if results else 0,
"median_s": round(statistics.median(latencies), 3) if latencies else None,
"p95_s": round(percentile(latencies, 95), 3) if latencies else None,
"bytes": total_bytes,
"outcomes": dict(Counter(r.outcome for r in results).most_common()),
}
if PRICE_PER_GB and wins:
cost = total_bytes / 1e9 * PRICE_PER_GB
summary["cost_per_1000_successes"] = round(cost / len(wins) * 1000, 4)
return summary
async def main(targets_path: str) -> None:
targets = load_targets(targets_path)
semaphore = asyncio.Semaphore(CONCURRENCY)
# No keep-alive: a reused tunnel pins one exit IP and hides how the pool rotates.
limits = httpx.Limits(max_keepalive_connections=0)
async with httpx.AsyncClient(
proxy=PROXY_URL, headers=HEADERS, timeout=TIMEOUT, limits=limits, follow_redirects=True
) as client:
jobs = []
for round_number in range(max(REQUESTS_PER_TARGET, ECHO_SAMPLES)):
if round_number < REQUESTS_PER_TARGET:
jobs += [fetch(client, semaphore, url, marker) for url, marker in targets]
if round_number < ECHO_SAMPLES:
jobs.append(fetch(client, semaphore, ECHO_URL, None, is_echo=True))
started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
started = time.perf_counter()
results = await asyncio.gather(*jobs)
wall = time.perf_counter() - started
by_target: dict[str, list[Result]] = {}
for result in results:
by_target.setdefault(result.target, []).append(result)
rows = [summarise(name, group) for name, group in by_target.items()]
overall = summarise("ALL TARGETS", [r for r in results if r.target != ECHO_URL])
exit_ips = {r.exit_ip for r in results if r.exit_ip}
print(f"\n{LABEL}: {len(results)} requests in {wall:.1f}s, concurrency {CONCURRENCY}\n")
print(f"{'target':<48} {'ok':>7} {'median':>8} {'p95':>8} {'MB':>8} outcomes")
for row in rows + [overall]:
median = f"{row['median_s']:.2f}s" if row["median_s"] is not None else "-"
p95 = f"{row['p95_s']:.2f}s" if row["p95_s"] is not None else "-"
print(
f"{row['target'][:48]:<48} {row['success_rate']:>7.1%} {median:>8} {p95:>8} "
f"{row['bytes'] / 1e6:>8.2f} {row['outcomes']}"
)
print(f"\nUnique exit IPs: {len(exit_ips)} of {ECHO_SAMPLES} echo requests")
if "cost_per_1000_successes" in overall:
print(f"Cost per 1,000 successful requests at ${PRICE_PER_GB}/GB: ${overall['cost_per_1000_successes']}")
with open(f"results-{LABEL}.json", "w", encoding="utf-8") as handle:
json.dump(
{
"label": LABEL,
"started": started_at,
"overall": overall,
"targets": rows,
"unique_exit_ips": len(exit_ips),
"raw": [asdict(r) for r in results],
},
handle,
indent=2,
)
if __name__ == "__main__":
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "targets.txt"))
Set the proxy URL from your provider's dashboard (for ProxyHive, copy the host, port, username and password from the order at https://app.proxyhive.io) and run it:
export PROXY_URL="http://USERNAME:PASSWORD@HOST:PORT"
export PROXY_LABEL="vendor-a"
export REQUESTS_PER_TARGET=500
export CONCURRENCY=10
export PRICE_PER_GB=4.00
python proxy_bench.py targets.txt
Repeat with the second provider's URL and PROXY_LABEL="vendor-b". Each run writes results-<label>.json, with a summary per target and every raw request.
What the output looks like
We ran the script on 2026-09-29 through a local authenticating test proxy (proxy.py on the same machine), with 50 requests per target and a deliberately failing target, https://httpbin.org/status/429, added to show a failure in the breakdown. These are a test of the script, not a benchmark of any provider:
local-test: 220 requests in 11.0s, concurrency 10
target ok median p95 MB outcomes
https://example.com 100.0% 0.17s 0.61s 0.04 {'200': 50}
https://httpbin.org/html 100.0% 0.62s 1.00s 0.20 {'200': 50}
https://www.wikipedia.org 100.0% 0.28s 0.61s 1.19 {'200': 50}
https://httpbin.org/status/429 0.0% - - 0.01 {'429': 50}
https://api.ipify.org?format=json 100.0% 0.29s 0.68s 0.00 {'200': 20}
ALL TARGETS 75.0% 0.35s 0.82s 1.44 {'200': 150, '429': 50}
Unique exit IPs: 1 of 20 echo requests
Cost per 1,000 successful requests at $4.0/GB: $0.0383
A local proxy has one exit IP, so "1 of 20" is the right answer here. We also ran it with a wrong password: HTTPS targets reported ProxyError (httpx raises it when the proxy refuses the tunnel) and the plain-HTTP target reported 407. With a marker that is not on the page, the outcome reads 200-marker-missing and counts as a failure.
Compare providers side by side
Once each provider has a results file, this prints them in one table:
import glob
import json
print(f"{'provider':<16} {'success':>8} {'median':>8} {'p95':>8} {'exit IPs':>9} {'$/1k ok':>9}")
for path in sorted(glob.glob("results-*.json")):
with open(path, encoding="utf-8") as handle:
run = json.load(handle)
o = run["overall"]
cost = o.get("cost_per_1000_successes", "-")
print(
f"{run['label']:<16} {o['success_rate']:>8.1%} {o['median_s'] or 0:>7.2f}s "
f"{o['p95_s'] or 0:>7.2f}s {run['unique_exit_ips']:>9} {cost:>9}"
)
How the script measures proxy success rate and speed
- Success is a 200 whose body contains the marker, if the target has one. Every other outcome is kept by name in the breakdown, so a 403 wall and a timeout storm look different.
- Latency runs from sending the request to reading the whole body, through a new connection each time. That includes the proxy handshake, which is what a rotating job pays on every request.
- Bytes are the compressed body bytes received plus the response headers. It is a floor, not the meter: request bytes, TLS handshakes and the proxy's own framing are not in it. Note your provider's usage counter before and after a run, and divide. The ratio tells you how to scale the script's figure to what you will be billed.
- Exit IPs come from the echo requests spread across the run, not bunched at the end, so they sample the pool while it is under your load.
- Cost per 1,000 successes divides the bytes from all target requests, failed ones included, by the successful ones. Failed requests cost traffic too.
Turn the numbers into a cost per successful request
For a per-GB product, set PRICE_PER_GB to your effective rate, not the headline: plan fees, tax and any traffic you expect to lapse included. The residential proxy pricing guide shows how to work that out, and why a monthly plan you do not fill costs more per GB than its label. Then scale the script's figure by the meter ratio you measured.
A worked example: provider A charges $4/GB and succeeds 70% of the time on your targets; provider B charges $5/GB and succeeds 95% of the time; your pages average 400 KB. A costs $0.0016 per attempt and $2.29 per 1,000 successes. B costs $0.0020 per attempt and $2.11 per 1,000 successes. The dearer gigabyte is the cheaper result, and that is before you count the engineering time spent on retries.
For per-IP products (static ISP and datacenter), the question is different. Divide the monthly price of the IP by the successful requests it can make in a month without being rate-limited on your target. A static IP that sustains your request rate for weeks is cheap per result; one that gets blocked on day two is expensive at any price. Run the script with a low concurrency over a longer window for static IPs, and watch the 429s.
Red flags in a proxy test
- Many 407s or proxy errors. Check the credentials and the URL format first. If they are right, the plan may have run out or the account may lack access to that product.
- A high share of
200-marker-missing. The target is serving block or challenge pages with a 200. That provider's exits are known to the target. The guide to avoiding blocks when scraping covers what you can change on your side. - Few unique exit IPs on a rotating pool. Fifty echo requests returning a handful of addresses means you are seeing a small slice of the pool, and a target will see the same few addresses too.
- A p95 five or more times the median. Some exits are slow or overloaded. At concurrency, your job runs at the speed of that tail.
- The control fails. Nothing else in the run means anything until
example.comsucceeds every time. - The meter disagrees wildly with the script. Some overhead is normal. A dashboard showing several times the bytes the script saw deserves a question to support, in writing.
- Results that swing between runs. Run at three times of day before you trust any single figure.
When the codes themselves are unfamiliar, the proxy error codes guide explains each one and what to do about it.
Run the test on ProxyHive
A new account gets 1 GB of residential traffic free, which is enough to run this benchmark on lighter pages before you spend anything. Open the order in the dashboard, copy the host, port, username and password into PROXY_URL, and run the same targets you use for everyone else. Residential rotates without location selection, so compare it against other vendors' untargeted pools, or note the difference. For a static ISP IP, expect exactly one exit IP, and use a longer, slower run.
We do not ask you to trust a number on our site: the benchmark is yours to run. Put the results next to the prices on our provider comparisons, where every competitor figure is dated and sourced, and the Python requests guide covers the proxy setup if you would rather test with a simpler client first.