DEV Community

Greta
Greta

Posted on

Every Exit IP Has a Carrying Capacity: Budgeting Logins-per-Exit Instead of Hoping

There is a number that governs the survival of every multi-account operation running through residential proxies, and almost nobody measures it: how many authenticated identities one exit IP can carry before the destination site starts pushing back. Teams pick "one account per IP" as dogma or, worse, share exits across accounts opportunistically because the pool "has thousands of IPs." Both extremes come from not knowing the actual capacity of the resource.

My argument: an exit IP is a capacity-constrained, degrading resource — closer to a database connection pool than to an address — and the right number of accounts per exit is not one, and not "whatever," but a quantity you can measure and budget like any other engineering limit. Once you frame it as capacity planning, several confusing behaviors of proxied fleets become predictable curves instead of superstition.

Why "one account, one IP" is not actually the safe answer

The naive reasoning goes: sites correlate accounts by IP, so never let two accounts touch the same IP. But this heuristic quietly ignores three realities.

First, IP sharing is the normal state of residential internet. A CGNAT exit routinely serves hundreds of households; a mobile exit can rotate across tens of thousands of subscribers in a day. A site that hard-banned any IP touched by two accounts would be banning half its legitimate user base. What sites actually build is a risk score per IP-identity interaction, and what matters is the density and timing of distinct identities, not mere co-occurrence.

Second, strict 1:1 mapping is expensive in a way that compounds: to hold N accounts you must hold N live exits, and residential exits are billed by traffic, not by count, but health is the scarce commodity. Holding a large set of exits forever means your per-exit traffic stays low, which sounds safe but actually leaves you blind — you never learn which exits are already half-burned by whoever used them before you.

Third, and this is the engineering point: 1:1 gives you no headroom to react. If an exit degrades, you have exactly one account to migrate, under pressure, mid-session. A capacity model with measured headroom gives you a planned migration instead.

So the real question is not "how do I enforce 1:1" but "what is the failure rate as a function of identities-per-exit, and where does it cross my budget?"

Measuring the capacity curve

The experiment is cheap and the data is already flowing through your logs. For each exit IP, track the distinct account identities that used it in a rolling window (I use 24 hours, because most fraud systems care about daily identity density), and the outcome of each authenticated request: clean response, soft block (login redirect, degraded page), or hard block (403/429 at the session layer). Then bin by identities-per-exit and compute the failure rate per bin.

Here is a self-contained capacity tracker you can bolt onto an existing fleet — it ingests request events and produces the load-vs-failure curve:

import collections
import math
from dataclasses import dataclass, field

@dataclass
class RequestEvent:
    ts: float          # unix seconds
    exit_ip: str
    account_id: str
    ok: bool           # False = any auth-layer failure (redirect to login,
                       # 403, 429, or CAPTCHA gate on an authed route)


class ExitCapacityTracker:
    def __init__(self, window_s: float = 24 * 3600, max_events: int = 500_000):
        self.window_s = window_s
        self.events: collections.deque = collections.deque(maxlen=max_events)

    def record(self, ev: RequestEvent) -> None:
        self.events.append(ev)

    def _prune(self, now: float) -> None:
        while self.events and self.events[0].ts < now - self.window_s:
            self.events.popleft()

    def load_table(self, now: float) -> list[dict]:
        """Rows: identities-per-exit bin -> observed failure rate."""
        self._prune(now)
        per_exit = collections.defaultdict(set)
        # outcome keyed by (exit, account) so we count per-identity verdicts,
        # not per-request noise from one bad identity
        verdict = {}
        for ev in self.events:
            per_exit[ev.exit_ip].add(ev.account_id)
            verdict[(ev.exit_ip, ev.account_id)] = ev.ok  # latest verdict wins

        bins = collections.defaultdict(lambda: [0, 0])  # n_ident, n_failed
        for exit_ip, idents in per_exit.items():
            load = len(idents)
            failed = sum(1 for a in idents if not verdict[(exit_ip, a)])
            b = min(load, 6)  # cap the bin index; 6 means "6+"
            bins[b][0] += len(idents)
            bins[b][1] += failed

        table = []
        for load in sorted(bins):
            n, failed = bins[load]
            table.append({
                "identities_per_exit": load if load < 6 else "6+",
                "n_identities": n,
                "failure_rate": round(failed / n, 4) if n else 0.0,
            })
        return table

    def safe_capacity(self, now: float, budget: float = 0.02,
                      min_sample: int = 50) -> int:
        """Largest load bin whose upper confidence bound stays under budget."""
        self._prune(now)
        for row in self.load_table(now):
            if row["identities_per_exit"] == "6+":
                break
            n, p = row["n_identities"], row["failure_rate"]
            if n < min_sample:
                continue
            if wilson_upper(p, n) <= budget:
                continue
            return max(1, int(row["identities_per_exit"]) - 1)
        return 1  # no bin proved safe: fall back to conservative 1:1


def wilson_upper(p_hat: float, n: int, z: float = 1.96) -> float:
    """Upper bound of the Wilson score interval — use it because naive
    failure rates on small bins are wildly optimistic."""
    if n == 0:
        return 1.0
    denom = 1 + z * z / n
    center = p_hat + z * z / (2 * n)
    spread = z * math.sqrt(p_hat * (1 - p_hat) / n + z * z / (4 * n * n))
    return (center + spread) / denom
Enter fullscreen mode Exit fullscreen mode

Two details in there are doing real work. The verdict is keyed by (exit, account) pairs rather than raw requests, because one dying account hammering retries would otherwise poison the whole bin. And the budget check uses the Wilson upper bound, because a bin with 12 observations and zero failures has a true failure rate that could be anywhere up to ~25% — you want your capacity number to survive small samples, not exploit them.

Run this for a week on a fleet that naturally spreads load, and you will almost always see the same shape: flat and low from 1 to some k identities per exit, then a knee, then a steep climb. In my experience with large residential pools the knee sits somewhere between 2 and 5 per exit per day for strict sites (social platforms, ticketing) and can be 10+ for lenient ones (classic e-commerce catalogs). The number is not transferable between sites, which is precisely why you measure instead of copying someone's blog heuristics — including mine.

Budgeting, not just observing

The measurement gives you a capacity number k. Capacity planning turns it into an assignment policy: when a lease needs an exit, draw from exits currently below k / 2 — running at half capacity leaves headroom for the exits you cannot yet drain, and it means the next identity assignment never has to consider an exit that is already at the line.

The failure budget also tells you what to do at the other end. Exits that collected failures get benched not when they "fail" (they do not — the accounts fail; the exit is fine and that asymmetry is exactly why capacity thinking is needed) but when their rolling identity-failure contribution crosses a threshold. A simple benched-exits queue with exponential cooldown — 1 hour, then 4, then a day — recovers most exits within a day, because the underlying reputation signal is usually transient load on a shared residential device, not a permanent scar.

One more consequence of the capacity view: it tells you when your pool is structurally too small. If the measured k for your target site is 3 and you need 300 accounts, you need at least 100 concurrently healthy exits — before you think about churn, cooldowns, and burn rate, which in practice means provisioning 2–3x that. Teams discover they are under-provisioned only when they are already deep into the failure knee, at which point every exit they add gets immediately loaded to the brim and the knee moves toward them. Sizing the pool from k up front avoids death-spiraling the very resource you are short of.

The mental model that sticks

An exit IP is not an address you rent; it is a shared, perishable channel whose tolerance for distinct identities is finite, site-specific, and measurable. One-account-per-IP is not safety, it is just the capacity you choose when you have no data. Run the tracker for a week, find your knee, budget below it, and keep headroom — and the entire "why do our accounts randomly die" genre of incident quietly disappears, replaced by a dashboard with a number you set yourself.

Disclosure: I use Thordata's residential proxies for this project. New users get 500MB free — code thor020 (10% off): https://www.thordata.com/?ls=uXcSHJzx&lk=02-tele

Top comments (0)