ScreenshotNeo

BlogGuides

How to Reduce Proxy Costs in Web Scraping

Lower scraping spend with a proxy ladder, caching, sticky sessions, pacing, and cost-per-record measurement.

By the ScreenshotNeo team1 October 20268 min read

Reduce proxy costs by using the cheapest network that reliably produces a complete record. Start with direct access where it is allowed, move to rotating datacenter proxies for targets that accept hosting ranges, and reserve residential proxies for targets that reject datacenter IPs or require residential geography. Then lower the number of bytes and retries with caching, deduplication, incremental fetching, narrow responses, conservative concurrency, and backoff.

Measure cost per successful record, not only price per gigabyte. A cheap proxy that causes blocks, incomplete pages, or repeated retries can cost more than a premium proxy that succeeds on the first attempt.

1. Build a proxy ladder

A proxy ladder escalates only the requests that need a more expensive network.

Target or workflow First option Escalate when
Public, lightly protected pages Direct access, where allowed The target blocks your normal network or requires a different location
High-volume pages accepting hosting ranges Rotating datacenter proxies 403 responses, CAPTCHAs, or degraded content persist after pacing changes
Strong IP reputation checks A small residential test slice The measured usable-record cost is lower than with datacenter traffic
Country, region, or city-specific collection The least expensive network that exposes the required geography The location is unreliable or the target returns a different view
Login, carts, pagination, or other multi-step flows A sticky session with consistent cookies The session expires or the target requires a new identity

Residential proxies are not automatically cheaper overall. Their higher per-gigabyte price is justified only when their success rate reduces the total cost of usable records.

2. Measure the bill before changing it

Before switching vendors or proxy tiers, record the request stages and products that generate usage. Dashboards and budget alerts help identify where spend is actually coming from.

  • Proxy bytes sent and received
  • Request count and response status
  • Retry count and retry reason
  • Block, CAPTCHA, and challenge rate
  • Latency and timeout rate
  • Cache-hit ratio
  • Complete, usable records
  • Exit geography and proxy tier

Calculate these values for each target and proxy tier:

proxy_cost = billed_proxy_bytes / bytes_per_billing_unit * price_per_unit
cost_per_success = (proxy_cost + compute_cost + storage_cost) / usable_records
retry_inflation = total_requests / first_attempt_requests
success_rate = usable_records / total_requests

Keep a row per target, day, proxy tier, and geography. This prevents a high-volume easy target from hiding an expensive blocked target.

Minimal Python cost report

from collections import defaultdict

# Replace these rows with exported request logs.
rows = [
    {"target": "catalog-a", "tier": "datacenter", "bytes": 2_100_000,
     "requests": 100, "usable": 96, "retries": 8, "price_per_gb": 1.00},
    {"target": "catalog-a", "tier": "residential", "bytes": 900_000,
     "requests": 30, "usable": 29, "retries": 1, "price_per_gb": 2.75},
]

summary = defaultdict(lambda: {"bytes": 0, "requests": 0, "usable": 0, "retries": 0, "cost": 0.0})
for row in rows:
    key = (row["target"], row["tier"])
    item = summary[key]
    item["bytes"] += row["bytes"]
    item["requests"] += row["requests"]
    item["usable"] += row["usable"]
    item["retries"] += row["retries"]
    item["cost"] += row["bytes"] / 1_000_000_000 * row["price_per_gb"]

for (target, tier), item in summary.items():
    per_record = item["cost"] / item["usable"] if item["usable"] else None
    print({
        "target": target,
        "tier": tier,
        "cost": round(item["cost"], 6),
        "usable_records": item["usable"],
        "cost_per_usable_record": None if per_record is None else round(per_record, 6),
        "retry_inflation": round(item["requests"] / max(item["requests"] - item["retries"], 1), 3),
    })

3. Cut requests and response bytes

Cache reusable responses

Cache pages and API responses whose contents do not need to be fetched again. Suitable longer TTLs, tiered caching, and cache rules can increase the hit ratio. A cache hit can avoid origin fetch costs, routing charges, and worker execution.

Use a cache key that includes every input that changes the response, such as URL, query parameters, locale, authorization scope, and relevant cookies. Never share authenticated or user-specific responses between users.

Deduplicate URLs

Normalize URLs before enqueueing them: remove tracking parameters that do not affect content, normalize host casing, and resolve redirects when your crawler can do so safely. Keep parameters that select a product, page, language, or sort order.

from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode

DROP = {"utm_source", "utm_medium", "utm_campaign", "fbclid"}
def canonical_url(url):
    parts = urlsplit(url)
    query = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
             if k not in DROP]
    return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path or "/",
                       urlencode(sorted(query)), ""))

Fetch only what changed

Store validators such as ETag and Last-Modified when the target supplies them. Send conditional requests and skip parsing when the server confirms that the representation is unchanged. If validators are unavailable, use a content hash and an application-level refresh interval.

Request only needed data

Prefer an endpoint that returns the fields your parser needs. Avoid downloading images, video, fonts, analytics, advertisements, and other assets that do not contribute to the dataset. Confirm that blocking an asset does not remove the data you need from client-side rendering.

4. Choose datacenter versus residential proxies

Use the lowest-cost tier that meets the target’s access and geography requirements. Vendor examples in the research include $1.00/GB for rotating datacenter proxies, $1.75/GB for budget residential, and $2.75/GB for premium residential from SpyderProxy. Node4 gives an example of $5.90 per month for 10 GB, or $0.59/GB at that volume. These are vendor examples, not market averages, and prices can change.

Option Best fit Cost risk Test metric
Direct Allowed, lightly protected targets Blocks can waste compute and retries Usable records per request
Rotating datacenter High volume and targets accepting hosting ranges IP reputation blocks Success rate and retry inflation
Residential Residential reputation or precise local geography Higher per-GB price Cost per complete record

Run a small A/B slice before moving an entire crawl. Compare complete records, response bytes, retries, latency, and blocks. A lower price per GB is a false economy when it produces incomplete pages.

5. Match rotation to workflow state

  • Independent requests: Per-request rotation can be appropriate when each request has no shared state.
  • Login and checkout: Keep one sticky identity with the same cookies and proxy for the session.
  • Pagination: Keep the identity stable when the target associates page tokens or cursors with a session.
  • 429 responses: Reduce concurrency and back off. Rotating faster does not fix an overloaded request pattern.

Use a session identifier in your queue so all steps in one workflow select the same proxy identity. Expire the session after a defined idle period or after the target invalidates it.

6. Pace requests and control retries

Set a per-target concurrency limit and add jitter so requests do not arrive in synchronized bursts. Retry only transient failures. Do not retry malformed URLs, authorization failures, or stable 404 responses.

import random, time

def backoff(attempt, retry_after=None):
    if retry_after is not None:
        return min(float(retry_after), 120)
    base = min(60, 2 ** attempt)
    return base * (0.5 + random.random())

for attempt in range(5):
    response = fetch_once()  # supply your HTTP client and proxy
    if response.ok:
        break
    if response.status_code not in {408, 425, 429, 500, 502, 503, 504}:
        raise RuntimeError(f"permanent failure: {response.status_code}")
    time.sleep(backoff(attempt, response.headers.get("Retry-After")))

7. A practical rollout plan

  1. Export seven days of request, byte, retry, block, and usable-record data.
  2. Separate targets by protection level, geography, and workflow state.
  3. Test direct access on targets where it is allowed.
  4. Test rotating datacenter traffic with conservative concurrency.
  5. Test residential traffic only on the blocked or geography-specific slice.
  6. Apply caching, deduplication, conditional fetching, and asset blocking.
  7. Run a validation scrape after every proxy configuration change. Confirm pages, selectors, and record completeness.
  8. Roll out the cheaper configuration gradually and keep a rollback tier.

8. Troubleshooting

Symptom Likely cause Fix
Bill rises while record count stays flat Retries, blocks, or duplicate URLs Measure retry inflation, canonicalize URLs, and stop retrying permanent errors
Many 403 responses or CAPTCHAs Datacenter IP reputation or bursty concurrency Reduce concurrency and pace requests; test a small residential slice
Pages are incomplete Assets were blocked too aggressively or the proxy changed geography Allow required resource types, keep the correct location, and validate selectors
Login breaks between pages Proxy rotation changed the session identity Use a sticky session with consistent cookies
429 responses increase after rotation Request rate is too high Back off, lower concurrency, and respect Retry-After
Cache hit ratio is low Unstable cache keys or a TTL that is too short Remove irrelevant query parameters and choose a TTL consistent with freshness needs
Cheap tier costs more per record Low success rate or repeated downloads Compare cost per usable record and keep the cheaper tier only where it wins

9. Performance, reliability, and cost checklist

  • Track bytes and usable records by target and tier.
  • Use direct access where allowed and technically reliable.
  • Prefer datacenter proxies for high-volume targets that accept hosting ranges.
  • Reserve residential proxies for reputation or geography requirements.
  • Keep stateful workflows sticky.
  • Cache, deduplicate, and fetch incrementally.
  • Block nonessential resources only after verifying page completeness.
  • Use bounded retries with exponential backoff and jitter.
  • Set concurrency per target instead of one global maximum.
  • Use budget alerts and review changes after each rollout.

Proxies also do not make a scrape permissible. As Node4’s proxy use-case guidance states, “Proxies also do not make a scrape permissible: a site’s terms and the law that applies to it are unaffected by where the request came from.” Review the target’s terms, applicable law, and your project’s compliance requirements.

10. Or skip the browser setup

If your workflow is collecting rendered page images or PDFs, ScreenshotNeo can remove browser infrastructure from that part of the pipeline. One GET request returns a PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers report the page verdict and billing status. An MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for options such as full-page capture, CSS selectors, device presets, custom headers and cookies, geolocation, caching, bulk capture, and asynchronous jobs.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo returned ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo includes 1,000 screenshots per month free with no card. Paid plans start at $5 for 3,000 shots, and every feature is available on every plan. Create a free ScreenshotNeo account.

11. FAQ

Should I rotate proxies for every request?

Only for independent, stateless requests when the target benefits from rotation. Keep a sticky identity for login, carts, pagination, and other multi-step workflows.

Are residential proxies always the safest cost choice?

No. Test them only where datacenter traffic fails or residential geography is required, then compare cost per usable record.

What is the fastest way to find waste?

Group usage by target and inspect bytes, retries, block rate, cache hits, and usable records together. Duplicate URLs and retry inflation commonly explain a rising bill.

How often should proxy pricing be compared?

Provider prices, pool composition, locations, and terms change. Recheck them before publishing a commercial comparison or moving a large workload.