“Proxy load balancing” is a phrase that means something very different than it used to. In the old model you bought a list of IPs and built a rotator: pick an IP, track its health, take it out when it died, put it back later. Teams still write that middleware out of habit, and on a modern gateway it’s mostly dead code.
On a residential proxy gateway, the pool balances itself. One endpoint, and the provider assigns an exit from millions of IPs per request or per session, server-side. You are not load-balancing IPs. What you are architecting is the layer above: how work maps onto identities, how much concurrency a target will tolerate, and what happens when a request fails. Get those three right and the system scales; get them wrong and no amount of proxy quality saves you.
This is a deep-dive on those three layers, for teams running collection pipelines in production.
What the gateway does, and where your architecture starts
Worth being precise about the split, because it determines what you should and shouldn’t build:
The gateway handles: exit IP selection from the pool, geo matching, health of individual IPs, and rotation mechanics. You express intent through the username (country, city, sid, ttl) and it resolves that to an exit.
You handle: the unit of identity (what gets its own IP, and for how long), concurrency control (how hard you push each target), retry policy (what happens on failure), and observability.
The most common architectural mistake is building a proxy-rotator layer that duplicates the gateway. If you find yourself maintaining an IP health table, stop, that’s the provider’s job, and your version has a worse view of the pool than they do. Your job starts at the request-policy level.
Layer 1: rotation architecture, choosing the unit of identity
This is the design decision everything else hangs off. The question isn’t “how often should I rotate?” but “what is one identity, and what work belongs to it?”
The two primitives:
- Per-request rotation (omit
sid): every request gets a fresh exit IP. Maximum IP diversity, zero connection reuse, ideal for large sets of independent fetches where no request depends on the last. - Sticky session (
sid+ttl): all requests carrying that session id share one exit IP until the TTL expires. Necessary when a flow must look like one user, and it’s also what makes pooled connections reusable (see sticky vs rotating).
The design rule: rotate at the boundary of a logical unit of work, not arbitrarily. A unit of work is whatever must be internally consistent, one product’s paginated reviews, one search flow, one account’s session. Map it cleanly:
one worker = one unit of work = one sid = one exit IP (for its TTL)Derive the session id from the work unit rather than randomly, so behavior is reproducible and you can decide deliberately whether a retry keeps or changes identity:
def session_id(job_id: str, attempt: int = 0) -> str: # Same job → same identity. Bump `attempt` to deliberately get a new IP. return f"{job_id}-a{attempt}"
def proxy_for(job_id, attempt=0, country="us", ttl=600): sid = session_id(job_id, attempt) user = f"{USER}-country-{country}-sid-{sid}-ttl-{ttl}" url = f"http://{user}:{PASS}@p.shifter.io:443" return {"http": url, "https": url}That single attempt parameter is doing real architectural work: it makes “retry on a different identity” a first-class, intentional operation instead of an accident.
Layer 2: concurrency architecture, and why it’s per-host
The constraint on concurrency is almost never your proxy plan (concurrent connections are typically not the limit). It’s what the target tolerates. Which means a single global concurrency limit is the wrong shape: it lets one permissive target starve a fragile one, and it lets a fragile one throttle your whole pipeline.
Limit per host, not globally:
import asynciofrom collections import defaultdict
# One semaphore per target host, tuned to what that host tolerates_limits = {"tough-site.example": 4, "open-site.example": 32}_sems = defaultdict(lambda: asyncio.Semaphore(8)) # sensible defaultfor host, n in _limits.items(): _sems[host] = asyncio.Semaphore(n)
async def fetch(client, url, host): async with _sems[host]: # backpressure applied per host return await client.get(url, timeout=30)Two properties this buys you. Isolation: a slow or hostile target can’t consume every worker. Tunability: you can raise concurrency on targets that don’t care and dial it down on the one that blocks, independently.
And resist the urge to crank the numbers. Past a target’s tolerance, extra parallelism doesn’t buy throughput, it buys blocks, and blocks cost you more in retries than the concurrency ever gained (reducing latency covers that trade).
Layer 3: retry architecture, the part that makes or breaks it
Most pipelines fail here. A naive for attempt in range(3): retry() turns a bad afternoon into a retry storm that amplifies blocks and burns bandwidth. A real retry layer does four things.
1. Classify before retrying. Not all failures are the same, and the response is different for each:
| Failure | Example | Right response |
|---|---|---|
| Transport | timeout, connection reset | Retry, same identity is fine |
| Rate limit | 429 | Back off hard, slow the whole host |
| Block | 403, CAPTCHA, soft block with 200 | Retry on a new identity, don’t reuse the IP |
| Permanent | 404, 400 | Don’t retry, record and move on |
Note the soft block: a 200 with a CAPTCHA page is a failure. If you classify on status code alone you’ll count blocks as successes and your pipeline will quietly fill with garbage (why scrapers get blocked).
2. Back off with jitter. Exponential backoff without jitter synchronizes your workers into a thundering herd that hits the target in waves. Always add randomness.
3. Change identity on blocks. Retrying a block on the same sticky IP is just asking the same rejection again. Bump the attempt counter so the session id changes and the gateway hands you a different exit.
import random, asyncio
async def fetch_with_policy(client, url, job_id, host, max_attempts=4): for attempt in range(max_attempts): proxies = proxy_for(job_id, attempt=attempt) # new identity per attempt try: r = await client.get(url, proxies=proxies, timeout=30) kind = classify(r) # ok | ratelimit | block | permanent if kind == "ok": return r if kind == "permanent": return None # don't waste attempts if kind == "ratelimit": await slow_down(host) # widen the host's pacing except (asyncio.TimeoutError, ConnectionError): pass # transport: retry backoff = (2 ** attempt) + random.uniform(0, 1) # jitter, always await asyncio.sleep(backoff) await dead_letter(job_id, url) # park it, don't block the pipeline return None4. Give up gracefully. After N attempts, park the item in a dead-letter queue and move on. Unbounded retries don’t rescue a job; they convert one failure into sustained load against a target that’s already refusing you.
Two more patterns worth building once you’re at scale: a circuit breaker per host (if success rate collapses, stop sending for a while rather than grinding), and idempotent work items, so a retry is always safe to run.
The reference shape
Put together, the pipeline looks like this:
work queue ↓per-host limiter (semaphore + pacing) ↓worker → session id (sid) → gateway → target ↓classify response ├── ok → validate content → emit ├── ratelimit → slow host → backoff + requeue ├── block → new identity → backoff + requeue └── permanent → record, drop ↓ (after N attempts)dead-letter queueEvery arrow is a decision you’d otherwise make implicitly and badly. Made explicit, the system degrades gracefully instead of collapsing.
Observability: you can’t operate this blind
Instrument at minimum: success rate per host (with content validation, not status codes), p50/p95 latency, retry rate and failure-type breakdown, and bytes per successful record. Those four tell you where to act, a rising retry rate on one host means tighten that host’s concurrency; a rising bytes-per-record means you’re pulling payload you don’t parse (cutting bandwidth costs). The measurement methods are in how to test proxy speed, success rate, and location accuracy.
Anti-patterns to avoid
- Building an IP rotator on top of a gateway. Redundant, and worse-informed than the provider’s own balancing.
- One global concurrency limit. Couples unrelated targets; limit per host.
- Retrying blocks on the same sticky IP. Same IP, same answer.
- Backoff without jitter. Synchronizes workers into waves.
- Unbounded retries. Amplifies blocks, burns bandwidth, delays nothing but the inevitable.
- Treating 200 as success. Soft blocks are 200s; validate content or your data quietly rots.
- Rotating mid-flow. Changing IP inside a multi-step session is a detection signal, rotate at unit boundaries.
FAQ
Do I need to build proxy load balancing myself? Not at the IP level. A gateway assigns exits from the pool server-side, so an IP rotator or health table on your side is redundant. Architect the layer above: identity units, per-host concurrency, and retry policy.
How many concurrent requests should I run? It depends on the target, not your plan. Set a limit per host based on what that host tolerates, and tune each independently. Past that point, more parallelism produces blocks, not throughput.
Should a retry use the same IP or a new one? Depends on the failure. Transport errors (timeouts) can retry on the same identity. Blocks and CAPTCHAs should retry on a new identity, changing the session id, since the IP is what got refused.
How do I stop retries from making blocking worse? Classify failures, back off exponentially with jitter, cap attempts, dead-letter what won’t succeed, and add a per-host circuit breaker. Unbounded, unclassified retries are how a small block problem becomes a large one.
What should I monitor in a proxy pipeline? Success rate per host (with content validation), p50/p95 latency, retry rate with failure-type breakdown, and bytes per successful record. Those four surface nearly every problem worth fixing.
The bottom line
On a gateway, load balancing the pool isn’t your problem, and pretending it is leads teams to build the wrong thing. The architecture that actually determines whether a collection pipeline scales lives in three decisions: what constitutes one identity and what work belongs to it, how much concurrency each individual target tolerates, and how failures are classified, backed off, re-identified, and eventually abandoned. Get those right, instrument them, and the pipeline degrades gracefully under pressure instead of falling over.
The gateway handles the rest. If you’re building this out, the residential gateway gives you rotation and sticky sessions through the username so your architecture stays in your code, not in a proxy-management layer, and pool quality determines how often that retry path is exercised at all (IP reputation). For large-scale considerations, see the best residential proxy network for large-scale scraping, and the pricing page has the per-GB plans to test the design against your own workload.