How Long Should You Hold an IP? Sizing Sticky Windows with Data Instead of Vibes
Every proxy provider gives you the same dial, and almost nobody turns it with evidence. The dial is the sticky session — hold one exit IP for a while instead of rotating every request — and the setting is its lifetime: 1 minute, 10 minutes, 30 minutes, or "until it dies." Teams pick this number by copying a tutorial, inheriting it from the previous codebase, or just leaving the provider default. Meanwhile, on the other side of the connection, the target site is running a scoring system that cares intensely about how many requests an IP has made and how fast.
The result is a real cost, paid in either direction. Hold an IP too long and you accumulate requests on a single address until the site's per-IP rate heuristics flag you — your success rate decays request by request, invisibly, because each individual failure looks like ordinary noise. Rotate too aggressively and you throw away something valuable: established reputation. Many sites give a fresh-but-plausible IP a short trust runway that improves with a handful of well-behaved requests, and sites that track sessions (carts, login flows, localized variants) break outright when the address changes mid-flow.
The core claim of this article: success rate as a function of requests-per-IP is a measurable curve, it usually has a knee, and the knee — not a default — is where your sticky window belongs. Here's how to measure it.
The experiment
The design is simple enough to be almost embarrassing, which is probably why teams skip it and guess instead.
Pick a target endpoint you can probe cheaply and identify success on (a 200 with parseable content = success; a 403/429 or a challenge page = failure). Then run a grid:
- Window sizes: how many requests to send through one sticky session before abandoning it — e.g., 5, 10, 20, 40, 80, 160.
- Repetitions: each window size gets many independent sessions, because you're estimating a distribution, not a single trajectory. Fifty sessions per window size is a reasonable floor.
- One pacing: keep the request interval fixed across all arms, because pace and accumulation interact. If you later change pace, re-run.
For each session, log every request with its ordinal position (request #1, #2, ... within the session) and its outcome. The dataset this produces — outcome vs request ordinal — is the whole point. From it you can compute the per-request success probability at each session age, the cumulative expected successes per session, and the knee.
One methodological trap to avoid: rotate the site of measurement out of a login or cart context unless that's what you're modeling. On a bare content endpoint, accumulation is mostly about per-IP rate heuristics; on a stateful flow, session-affinity effects dominate and will flatten your curve in ways that don't generalize. Measure the context you actually run in, or measure both.
The harness
# sticky_sizing.py -- measure success rate vs requests-per-IP.
# Python 3.8+, stdlib + requests.
import json
import random
import sqlite3
import time
import uuid
import requests
DB = sqlite3.connect("sticky_experiment.db")
DB.execute("""CREATE TABLE IF NOT EXISTS probe (
session_id TEXT, window_size INTEGER, req_ordinal INTEGER,
ok INTEGER, status INTEGER, ts_utc TEXT)""")
PROXY_TMPL = ("http://youruser-country-us-session-{sid}:yourpass"
"@gw.thordata.com:8000")
TARGET = "https://example.example/api/listings?page={p}"
WINDOWS = [5, 10, 20, 40, 80, 160]
SESSIONS_PER_WINDOW = 50
REQ_INTERVAL = 2.0 # seconds; keep constant across arms
def proxies(sid):
url = PROXY_TMPL.format(sid=sid)
return {"http": url, "https": url}
def fetch(session, ordinal):
resp = session.get(
TARGET.format(p=random.randint(1, 50)),
timeout=30,
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"},
)
ok = 1 if (resp.status_code == 200
and "challenge" not in resp.text[:2000].lower()) else 0
DB.execute("INSERT INTO probe VALUES (?,?,?,?,?,datetime('now'))",
(session_sid, window, ordinal, ok, resp.status_code))
return ok
def run_window(window):
global session_sid
for s in range(SESSIONS_PER_WINDOW):
session_sid = f"w{window}-s{s}-{uuid.uuid4().hex[:6]}"
sess = requests.Session()
sess.proxies.update(proxies(session_sid))
for i in range(1, window + 1):
try:
ok = fetch(sess, i)
except Exception:
DB.execute("INSERT INTO probe VALUES (?,?,?,?,?,datetime('now'))",
(session_sid, window, i, 0, -1))
ok = 0
DB.commit()
if not ok and i > 3:
# session looks burned; record and abandon early
break
time.sleep(REQ_INTERVAL)
def analyze():
# survival-style success rate by request ordinal
rows = DB.execute(
"""SELECT req_ordinal, SUM(ok), COUNT(*) FROM probe
GROUP BY req_ordinal ORDER BY req_ordinal""").fetchall()
curve = [{"req": r, "p_ok": round(ok / n, 3), "n": n}
for r, ok, n in rows]
# cumulative expected successes per session, per window size
windows = DB.execute(
"SELECT DISTINCT window_size FROM probe").fetchall()
per_window = {}
for (w,) in windows:
sess_rows = DB.execute(
"""SELECT session_id, SUM(ok) FROM probe WHERE window_size=?
GROUP BY session_id""", (w,)).fetchall()
totals = [ok for _, ok in sess_rows]
per_window[w] = {
"mean_successes": round(sum(totals) / len(totals), 2),
"successes_per_req": round(
sum(totals) / (len(totals) * w), 4),
}
# knee: last ordinal where p_ok is still >= 90% of the request-1 rate
first = curve[0]["p_ok"] or 1.0
knee = next((c["req"] for c in curve
if c["p_ok"] < 0.9 * first), None)
print(json.dumps({"curve": curve, "per_window": per_window,
"knee_ordinal": knee}, indent=2))
if __name__ == "__main__":
for window in WINDOWS:
run_window(window)
analyze()
A note on the early-abort in run_window: if a session is failing repeatedly, continuing to send requests measures nothing you care about and burns traffic. Abandoning and counting the session as "dead at ordinal i" is the survival-analysis framing — each session contributes outcomes up to its death, and the aggregate curve is the success probability conditioned on having survived that long, which is exactly the quantity you want for sizing.
Reading the curve
Four shapes show up in practice, and each implies a different window:
- Flat forever. Success probability barely decays across hundreds of requests. Either the target doesn't do per-IP accumulation, or your pace is slow enough that you're under every threshold. Use long sessions for stateful work; rotate on a fixed wall-clock interval (say 30 minutes) just for hygiene.
- Gentle decay, no cliff. p_ok drifts down maybe 10–15% over the first 50 requests. The knee heuristic in the code finds where decay crosses 10% of baseline; size the window just below it. Most large content sites land here.
- Cliff. Flat, flat, flat, then a wall — classic per-IP counters with hard thresholds (N requests per rolling hour). The cliff location is your window, minus safety margin, and note that the cliff often depends on pace, so the window you measure at 2-second intervals will be wrong at 200 ms.
- Warm-up then decay. The first 3–5 requests have lower success than requests 5–30 — the trust-runway effect. If you see this, aggressive rotation is actively hurting you: every rotation re-pays the warm-up tax. This shape argues for sticky windows at least as long as the warm-up region, even if the eventual decay is steep.
The per_window output settles the economical view: successes_per_req is success density. The best window is the largest one before density starts dropping — hold IPs as long as the marginal request is as likely to succeed as the average one, and no longer.
From measurement to production
Two things change when you take this from experiment to pipeline. First, log session age in production (request ordinal within the current sticky session, which the previous article's geo-stamping schema covers). The experiment gives you a prior; production logging tells you when the target's behavior has shifted and the knee has moved. A weekly query of success rate by session age over the last 7 days of production traffic is your drift alarm.
Second, make the window a per-target configuration, not a global constant. Different targets have different curves — I've run the same grid against two sites in the same vertical and gotten a cliff at 60 requests on one and flat-to-200 on the other. A shared global window over-trusts one and over-rotates the other.
There's a subtle interaction worth knowing: window sizing and pace are coupled, and providers' sticky lifetimes are usually wall-clock (1/10/30 min), not request-count. Convert your request-count knee into wall-clock using your production pace (window_minutes = knee × interval_seconds / 60), then pick the provider lifetime just above it, and enforce the request-count cap in your own code as the real control — the provider lifetime is a ceiling, your cap is the policy.
None of this is clever. It's a grid, a log, and a curve. But the difference between a window set by measurement and one set by default is routinely a 2× swing in success density — the same traffic budget, the same pace, dramatically more usable data per proxy dollar. Turn the dial with data.
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)