Every proxy provider's pricing page is a personality test: per-GB plans for the volume monsters, per-IP monthly rentals for the stability crowd, per-request pricing for the API-callers. Teams pick a plan the way they pick a phone contract — by vibes and list price — and then spend a year either subsidizing the provider (paying for capacity they never touch) or drowning in overage fees (because their traffic shape never fit the plan).
The truth is more boring and more useful: which plan is cheapest is a deterministic function of your traffic shape — the distribution of your payload sizes, your request counts, and your failure/retry rate. Not of list prices. List prices are inputs; your traffic shape is the equation. And your traffic shape is something you can measure, model, and simulate in a hundred lines of Python before you commit to a contract.
My argument: never choose a billing model without first simulating it against your own measured traffic. Here is how to build that simulation, and the three traffic-shape archetypes that decide the answer.
The three billing models, mechanically
Per-GB charges for bytes through the gateway — request plus response, headers included. Your cost is dominated by response size. A scraper pulling 40 KB product pages pays for every kilobyte of navigation chrome it fails to strip; a scraper hitting compact JSON APIs pays almost nothing.
Per-IP charges rent per concurrent exit or per static address per month. Your cost is dominated by how many distinct identities you need simultaneously, and is completely indifferent to volume. Ten thousand requests through one exit cost the same as ten.
Per-request charges a flat fraction of a cent per fetch, whatever the payload. It behaves like per-GB for small payloads and wildly overcharges for large ones.
The crossover points between these models are entirely determined by two numbers you can compute from your own logs: average bytes per request and requests per exit per month.
Step 1: measure your traffic shape
You need three distributions from your own pipeline, not estimates: payload bytes per successful request, requests per day, and retry factor (requests issued per successful row, which captures failure waste). If you do not log bytes yet, start — one line in your fetch wrapper:
import time
def fetch_logged(session, url, origin, meter):
t0 = time.perf_counter()
r = session.get(url, timeout=20)
meter.append({
"origin": origin,
"status": r.status_code,
"bytes": int(r.headers.get("Content-Length", len(r.content))),
"ms": (time.perf_counter() - t0) * 1000,
})
return r
Two weeks of that gives you everything. The measurement that surprises people most is effective bytes per request: after you account for the 4 KB of headers, the uncompressed HTML nobody asked for, and the images your parser never touched, "scraping an API" and "scraping a page" differ by an order of magnitude in per-GB cost.
Step 2: the simulator
import random
from dataclasses import dataclass
# --- measured traffic shape (fill from your logs) ---
@dataclass
class TrafficShape:
requests_per_day: int
mean_kb: float # measured avg response+request size
p95_kb: float # tails matter for per-GB plans
retry_factor: float # e.g. 1.3 = 30% of requests are retries/failures
concurrent_exits: int # distinct identities needed simultaneously
# --- candidate plans (list prices; sanity-check against the real quote) ---
@dataclass
class Plan:
name: str
model: str # "per_gb" | "per_ip" | "per_request"
price: float
unit: str # "per GB" | "per exit-month" | "per 1k requests"
def monthly_cost(shape: TrafficShape, plan: Plan, days: int = 30,
n_sim: int = 2000) -> tuple[float, float]:
"""Monte Carlo the monthly bill; returns (expected cost, p95 cost)."""
bills = []
eff_requests = shape.requests_per_day * shape.retry_factor * days
for _ in range(n_sim):
if plan.model == "per_gb":
# log-normal-ish size distribution with a fat tail at p95
kbs = [random.lognormvariate(
__import__("math").log(shape.mean_kb),
0.6) for _ in range(int(eff_requests))]
gb = sum(min(k, shape.p95_kb * 3) for k in kbs) / 1e6 # cap pathological tails
bills.append(gb * plan.price)
elif plan.model == "per_ip":
bills.append(shape.concurrent_exits * plan.price)
else: # per_request
bills.append(eff_requests / 1000 * plan.price)
bills.sort()
return (sum(bills) / len(bills), bills[int(0.95 * len(bills))])
# example: a 12k-requests/day pipeline over compact JSON endpoints
shape = TrafficShape(
requests_per_day=12_000, mean_kb=18.0, p95_kb=60.0,
retry_factor=1.25, concurrent_exits=40,
)
plans = [
Plan("residential per-GB", "per_gb", 4.50, "per GB"),
Plan("isp per-IP rental", "per_ip", 3.20, "per exit-month"),
Plan("api per-request", "per_request", 0.60, "per 1k requests"),
]
for p in plans:
exp, p95 = monthly_cost(shape, p)
print(f"{p.name:24s} expected ${exp:8.0f}/mo p95 ${p95:8.0f}/mo")
Run this with your numbers and the vibe-based decision disappears. The example shape above lands at roughly: per-GB ≈ $35/month (about 8 GB of effective traffic), per-IP ≈ $128/month, per-request ≈ $270/month. Same pipeline, same list prices, a 7× spread — entirely determined by the fact that this traffic is small-payload and high-count.
The three archetypes
After running this simulation across a bunch of real pipelines, the decision collapses into three archetypes:
Small-payload, high-count (API-heavy). Per-GB wins whenever mean payload is under ~30 KB, because the bytes simply do not accumulate. Per-request pricing is structurally hostile to you — you are paying a per-call premium that assumes each call moves real data. Per-IP only competes if your exit count is tiny.
Large-payload, low-count (page-heavy, media, full HTML). Per-IP rental or per-request can beat per-GB dramatically, because per-GB charges you for every byte of page chrome. This is the archetype where "strip the payload before it crosses the gateway" matters most — response filtering and requesting mobile/light variants of pages can halve a per-GB bill on its own.
Identity-dominated (many sticky accounts, static exits). If your concurrent-exit count is driven by account architecture rather than volume, per-IP is the natural model and volume-based plans will punish your retry factor mercilessly. Watch the retry factor: at 1.5× you are paying for half again as much traffic as you get rows, and no plan fixes that — only better routing does.
The option most teams miss: mix the models
Nothing forces your whole pipeline onto one plan. Once the simulator shows you which archetype each flow belongs to, the cheapest architecture is often split routing across billing models — API-heavy origin classes on a per-GB plan, the identity-dominated account fleet on per-IP rentals, and the handful of bursty one-off jobs on a metered per-request tier. The routing layer is the same tier-router you should already have; only the cost table behind it changes:
def plan_for(flow) -> str:
if flow.payload_profile == "json_api" and flow.identity_need == "rotating":
return "per_gb" # small payloads, high counts
if flow.identity_need in ("sticky", "static"):
return "per_ip" # cost follows identities, not volume
if flow.volume_per_month < 50_000:
return "per_request" # bursty, low-commitment
return "per_gb"
The one discipline this demands is metering per flow, not per account. If you cannot attribute gateway traffic to the flow that generated it, you cannot verify that the split is still correct three months later — and drift is the whole failure mode this exercise exists to prevent. Tag every request with its flow name in your logs, reconcile monthly against the invoices, and re-run the simulator with the updated shape whenever the reconciliation gap exceeds about ten percent.
The caveats that actually bite
Simulate the worst month, not the average. Per-GB overage tiers are steep, and the p95 column in the simulator exists because the month your scraper goes feral on retries is the month the bill triples. If the p95-to-expected gap on a plan exceeds ~2×, that plan is fragile against your failure modes regardless of its expected cost.
Model exit count honestly. Per-IP plans charge for what you hold, not what you use, and sticky-session architectures have a way of accumulating exits nobody released. The simulator's concurrent_exits should come from your session table's high-water mark, not its average.
Re-run quarterly and on every traffic-shape change. Adding a new origin class, switching a scraper from HTML to JSON, or fixing a retry storm all move the crossover points. The simulation is cheap; the annual contract is not.
None of this requires the providers' prices to be secret or their comparison charts to be honest. Your logs already contain the only variable that decides the question — what your traffic actually looks like. Price the plans against your own distribution, and the choice stops being a personality test and becomes arithmetic.
Top comments (0)