DEV Community

Greta
Greta

Posted on

Size Your Exit Pool With Arithmetic, Not Anxiety: A Capacity Planner for One-Account-One-IP Fleets

Size Your Exit Pool With Arithmetic, Not Anxiety: A Capacity Planner for One-Account-One-IP Fleets

Every team that runs an authenticated, one-account-one-IP fleet eventually faces the same procurement question: how many proxy exits do I actually need? The answers you'll hear in the wild are all folklore — "get double what you think," "just buy the biggest pool," "start with 50 and see." Meanwhile the actual decision is sitting right there in your own telemetry, expressible as four numbers and one formula.

The folklore is expensive in both directions. Over-provision and you pay for exits that never serve a request — and with residential per-GB or per-IP pricing, "never used" is pure waste. Under-provision and your assignment layer starts sharing exits between accounts under pressure, which quietly dismantles the isolation you built the whole architecture for.

The core idea of this article: exit pool size is not a guess about the future — it is a steady-state calculation over your account count, session turnover rate, and burn rate. Build the planner once, feed it your own numbers, and the "how many IPs" question becomes a spreadsheet row instead of a debate.

The four inputs

Everything reduces to four measurable quantities. You do not need to estimate any of them by vibes; each one is directly observable in a running fleet.

1. Account population (A). How many authenticated identities you operate. Trivial to count, but note it is the active population that matters — accounts in a warm-up phase, in cooldown, or retired should be counted separately, because they consume exits differently (warm-up accounts need an exit but at near-zero request rates; retired accounts should hold none).

2. Turnover rate (λ). How often a (account, exit) pairing dies and needs replacement. This is the number teams consistently underestimate. Pairings die from: origin-side de-authentication, sticky-window expiry that cannot be renewed on the same exit, planned rotation policy (e.g., "rotate every 7 days"), and exits quarantined after an incident. λ is measured in pairings-per-day and is best estimated from your retirement log — the same log the lease reaper from session-lifecycle hygiene produces naturally.

3. Recovery latency (R). How long a dead pairing takes to replace with a warm, usable one. This includes acquiring a fresh exit, warming it (a handful of low-risk anonymous requests), and re-authenticating. If your pipeline warms exits before login, R might be 10 minutes. If it logs in cold, R is 0 but your cold-start failure rate goes up — a trade worth pricing explicitly.

4. Isolation slack (s). The fraction of the pool you are willing to keep vacant so that no account is ever forced to share an exit during turnover peaks. This is the only genuinely policy-shaped input. A fleet that can tolerate brief exit-sharing sets s low; a fleet where cross-account correlation is catastrophic sets s high.

The formula

The steady-state requirement is:

pool_size = A + (λ * R) + (s * A)
Enter fullscreen mode Exit fullscreen mode

That is: one exit per active account, plus the pipeline of replacements currently in flight (turnover times recovery latency — Little's Law applied to pairing replacement), plus your isolation slack. Let's put real numbers on it. Suppose:

  • A = 300 active accounts
  • λ = 40 pairings die per day (≈13% daily turnover — typical for residential exits under active rotation policy)
  • R = 0.5 hours
  • s = 20% isolation slack

The in-flight replacement pipeline is λ * R only if λ and R share units. Convert: 40 pairings/day = 1.67 pairings/hour. In-flight = 1.67 * 0.5 = 0.83. So:

pool_size = 300 + 0.83 + 60 = ~361 exits
Enter fullscreen mode Exit fullscreen mode

Now watch what the planner does to two common intuitions. First, "recovery is fast so it doesn't matter": if you log in cold (R ≈ 0), the pool need is ~360 — but your failure rate on first requests rises, and each failed login is itself a risk event against the account. The planner makes that trade visible instead of implicit. Second, "turnover is what kills you": double λ to 80/day with a 4-hour recovery because you warm exits lazily, and the in-flight term jumps to 13.3 — still small here, but at A=3,000 accounts with λ=800/day and R=2h, in-flight becomes 67 exits that exist only to cover churn. Churn, not headcount, is what scales your bill in large fleets.

Here is the planner as a runnable module — including the parts that turn it from arithmetic into an operational tool:

from dataclasses import dataclass


@dataclass
class FleetInputs:
    active_accounts: int          # A
    retirements_per_day: float    # lambda, from your pairing-retirement log
    recovery_hours: float         # R, exit acquisition + warmup + login
    isolation_slack: float        # s, policy: 0.0 = share on pressure


def plan_pool(inp: FleetInputs) -> dict:
    turnover_per_hour = inp.retirements_per_day / 24.0
    in_flight = turnover_per_hour * inp.recovery_hours
    base = inp.active_accounts
    slack = inp.isolation_slack * inp.active_accounts
    total = base + in_flight + slack
    return {
        "base_exits": base,
        "in_flight_replacements": round(in_flight, 1),
        "isolation_slack": round(slack, 1),
        "recommended_pool": int(total // 1 + 1),  # ceiling
        "churn_share": round(in_flight / total, 3),  # fraction of pool owed to churn
    }


inp = FleetInputs(
    active_accounts=300,
    retirements_per_day=40.0,
    recovery_hours=0.5,
    isolation_slack=0.20,
)
print(plan_pool(inp))
# {'base_exits': 300, 'in_flight_replacements': 0.8,
#  'isolation_slack': 60.0, 'recommended_pool': 361, 'churn_share': 0.002}
Enter fullscreen mode Exit fullscreen mode

Where the numbers come from (and how to trust them)

The planner is only as honest as its inputs, so wire it to measurement rather than memory:

  • λ from retirement events, not from feelings. Count (account, exit) pairings that hit a terminal state — lease RETIRE verdicts, sticky-window expiries you chose not to renew, policy rotations — over a trailing 7-day window, divide by 7. Weekly windows absorb weekday/weekend traffic asymmetry.
  • R from your own pipeline timestamps. Instrument three moments: exit acquired, warm-up complete, login confirmed. R is the sum. If you can't instrument it, that's a finding in itself — an unmeasured recovery path is usually the slowest one.
  • A from the assignment layer's source of truth, not from the account database. The account DB knows what exists; the assignment layer knows what's deployed.

A useful addition is sensitivity output — how the recommendation responds to each input, so you know which measurement error actually matters:

def sensitivity(inp: FleetInputs, attr: str, factors=(0.5, 1.0, 2.0)):
    out = {}
    for f in factors:
        perturbed = FleetInputs(**{**inp.__dict__, attr: getattr(inp, attr) * f})
        out[f] = plan_pool(perturbed)["recommended_pool"]
    return out

print(sensitivity(inp, "retirements_per_day"))
# {0.5: 361, 1.0: 361, 2.0: 361}   <- lambda barely moves the answer at this scale
print(sensitivity(inp, "isolation_slack"))
# {0.5: 331, 1.0: 361, 2.0: 421}   <- slack moves it a lot
Enter fullscreen mode Exit fullscreen mode

At this fleet size, halving or doubling turnover changes nothing, while the isolation-slack policy dominates. That insight redirects the whole conversation: stop optimizing turnover recovery you don't need, and go argue about (or measure the real cost of) slack instead. In bigger fleets the sensitivities flip — which is exactly why you want the planner rather than a rule of thumb inherited from someone else's fleet size.

Turnover is a cost lever, not a weather condition

The deepest use of the planner is not procurement — it is discovering that λ is yours to engineer. Every component of turnover has a lever:

  • Quarantine quality. If burned exits are reused too early, they burn the next pairing too, inflating λ. A disciplined quarantine cool-down is turnover reduction.
  • Sticky-window alignment. Pairings that die purely because the proxy's sticky window lapsed mid-session are preventable: set your lease clocks to renew or re-pin before the window closes. Each prevented death is a pairing that never enters λ.
  • Warm-up discipline. Fresh pairings that skip warm-up fail at higher rates; each failure is a replacement cycle. R and λ are coupled through warm-up policy, and the planner exposes the coupling: cutting R by logging in cold often raises λ. Run both configurations through the planner and compare recommended_pool — sometimes the "slower" pipeline needs a smaller pool.

Treat the pool plan the way capacity planners treat server headcount: a living calculation, recomputed weekly from logs, with sensitivity analysis telling you which input to measure more precisely next.

The anxiety-driven alternative — buy 3x and never think about it — has a hidden failure mode beyond cost: oversized pools dilute your per-exit history. When every exit serves requests rarely, your own health signals (success rates per exit, retirement clustering per subnet) get statistically thin, and your routing decisions get dumber. A right-sized pool is not just cheaper; it is more observable.

Do the arithmetic once, wire it to your retirement log, and the next time someone says "should we buy more IPs," the answer is a number, a formula, and a sensitivity table — not a hunch.


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)