Somewhere in every fleet operator's logs there is a pattern hiding in plain sight: the requests that fail are disproportionately the first requests. Not the thousandth request through a warm connection, not the one that hit a rate limit — the very first page fetch after a fresh account–exit pairing goes live. Teams see the aggregate failure rate, blame "the site," and never segment by the age of the pairing.
The claim: fresh account–exit pairings are the highest-risk moment in the entire lifecycle of a proxied identity, and a deliberately slow ramp of the first few dozen requests — an exit warm-up — buys more survival than almost any fingerprint tuning you can do afterward. This is a proxy-layer discipline first and a browser-layer one second. If your scheduler routes a new pairing straight into full-rate traffic, you are spending your risk budget at the worst possible exchange rate.
Why the first requests are the expensive ones
Three separate mechanisms converge on the same window.
Trust has no prior. When an account's first request arrives at an exit the fraud system has never seen that account on, every signal is at its noisiest: no established request cadence, no history of "normal" for this IP-identity pair, no warmed TCP/TLS connections. Scoring systems handle absence of evidence poorly and conservatively — which is to say, they score you down for being new. The first requests are the ones where a single anomaly has maximum weight, because there is no base rate to dilute it.
Connection establishment is itself a burst. The first request through a fresh exit opens new TCP connections, performs new TLS handshakes, and typically triggers a cascade of follow-up fetches (assets, XHR, prefetch) that a warm page view would spread over existing connections. From the origin's view, a new identity arriving at a new IP opens six to ten connections in a second — a signature that no relaxed human session produces.
Proxy-side cold start compounds it. Residential exits frequently need their first request to negotiate upstream (a device waking, a route being established), which adds latency jitter precisely when your client's timeout and retry logic is most likely to fire. A retry storm on request number one through a pairing is the single most self-destructive sequence in this entire domain — you send the anomaly and the suspicious retry pattern in the same first second.
So the engineering response is to make the beginning deliberately boring: a ramp-up schedule that shapes the first hour of a pairing's traffic so it resembles the organic acceleration of a real user session, rather than the cold start of a worker process.
The ramp, concretely
A warm-up schedule I have used across several fleets, expressed as a request budget per pairing age:
| Pairing age | Max requests/min | Notes |
|---|---|---|
| 0–2 min | 1–2 | Landing page only, full render, no authenticated actions |
| 2–10 min | 5 | Read-only routes, one connection per route |
| 10–30 min | 15 | Begin normal read patterns |
| 30–60 min | 40 | Reach working rate |
| 60+ min | full budget | The pairing is warm; only standard limits apply |
The absolute numbers matter less than the shape: geometric-ish growth, an initial phase that is read-only, and a hard rule that no authenticated write (post, purchase, message) happens before the pairing is at least 10 minutes old. Writes are where fraud systems spend their strictest scrutiny, and a write from a minutes-old identity at a fresh exit is the single loudest anomaly you can generate.
Here is a runnable warm-up governor. It is deliberately boring code — the value is in the policy table and the instrumentation, not cleverness:
import time
import collections
RAMP = [
# (age_seconds, max_requests_per_minute, allow_authenticated_writes)
(0, 2, False),
(120, 5, False),
(600, 15, True),
(1800, 40, True),
(3600, None, True), # None = pairing is warm, no ramp cap
]
class WarmUpGovernor:
"""Per-pairing request admission control.
Keys are (account_id, exit_ip): the unit that needs warming is the
*pairing*, not the account and not the exit separately. An account
returning to an exit it used yesterday still deserves a short ramp —
but see `familiar_bonus` for prior-history discounting.
"""
def __init__(self, ramp=RAMP, familiar_bonus: float = 0.5):
self.ramp = ramp
self.familiar_bonus = familiar_bonus
self.state = {} # key -> {"born": ts, "hits": deque[ts]}
def _key(self, account_id: str, exit_ip: str) -> tuple:
return (account_id, exit_ip)
def _stage(self, age: float) -> tuple:
cap, writes = 2, False
for threshold, c, w in self.ramp:
if age >= threshold:
cap, writes = c, w
return cap, writes
def admit(self, account_id: str, exit_ip: str, is_write: bool,
now: float = None) -> tuple[bool, str, float]:
"""Returns (allowed, reason, seconds_until_would_be_allowed)."""
now = now if now is not None else time.time()
key = self._key(account_id, exit_ip)
st = self.state.setdefault(key, {"born": now,
"hits": collections.deque()})
age = now - st["born"]
cap, writes_allowed = self._stage(age)
# familiar pairing: seen together within 48h -> halve the ramp ages
if self._was_familiar(key, now):
age = age / max(self.familiar_bonus, 1e-6)
cap, writes_allowed = self._stage(age)
if is_write and not writes_allowed:
wait = self._time_until_writes_ok(st["born"], now)
return False, "WRITE_BEFORE_WARM", wait
if cap is None:
st["hits"].append(now)
return True, "WARM", 0.0
# sliding one-minute window
hits = st["hits"]
while hits and hits[0] < now - 60:
hits.popleft()
if len(hits) >= cap:
wait = 60 - (now - hits[0])
return False, "RAMP_CAP", max(wait, 0.0)
hits.append(now)
return True, "RAMP_OK", 0.0
def _time_until_writes_ok(self, born: float, now: float) -> float:
write_threshold = next(t for t, c, w in self.ramp if w)
return max(0.0, (born + write_threshold) - now)
def _was_familiar(self, key: tuple, now: float) -> bool:
# wire this to your pairing-history store; simplest version:
# the pairing existed in a previous process run within 48h.
return getattr(self, "_history", {}).get(key, 0) > now - 48 * 3600
# usage inside a fetch wrapper:
gov = WarmUpGovernor()
allowed, reason, wait = gov.admit(account_id, exit_ip, is_write=False)
if not allowed:
schedule_retry_in(wait) # or park the task on a delayed queue
A few notes on the pieces that matter in production. The sliding window is per pairing, keyed (account_id, exit_ip) — neither key alone is the right unit, because the risk being managed is precisely the novelty of their combination. The write gate is separate from the rate cap because they guard different things: the cap shapes volume, the gate defers scrutiny-heavy actions. And the familiar-pairing discount exists so that a fleet with stable account–exit assignment (which you want anyway, for reasons I will get to) does not pay the full cold-start tax every morning — but only if the pairing genuinely re-occurred within a couple of days. "Familiar" decays fast, as it should.
The retry interaction — where most fleets break this
The warm-up governor only works if your retry layer respects it, and by default it will not. The failure sequence I have watched in real fleets: first request through the fresh pairing is slow (cold proxy path), the client's 10-second timeout fires, the retry layer immediately re-fires — the governor now sees three requests in the first twenty seconds of the pairing's life, and the origin sees a new identity at a new IP hammering the same URL. You built a warm-up ramp and your timeout logic drove a truck through it.
Two rules close the hole. First, retries during the first stage of the ramp must be scheduled through the governor — RAMP_CAP verdicts get parked on a delayed queue, never bypassed by a retry loop that considers itself higher priority. Second, the timeout for a pairing's first few requests should be 3–4x your normal timeout, because residential cold-path latency is real and is not a failure. Treating cold-start slowness as an error is how you manufacture the exact burst pattern you built the ramp to avoid.
What the metrics show
Segmenting a fleet's failure rate by pairing age, before and after adding the ramp, tells the whole story compactly. Before: the 0–5 minute bucket failed at several times the rate of the 30–60 minute bucket — the cold-start cliff. After: the 0–5 minute bucket's request volume is of course much lower (that is the point), and its failure rate drops to near parity with warm buckets. Total throughput per account fell maybe 10–15% in the first hour of each pairing's life and was fully recovered thereafter; account survival over a week rose enough to dwarf that cost. You are not losing capacity, you are moving it out of the window where it buys the least.
There is a deeper principle here that extends past proxies: risk in automated systems is not uniform over a session's lifetime, and admission controls that are uniform (one global rate limit, one global retry policy) are spending their protection evenly across a risk curve that is anything but even. Warm-up is just the application of that principle at the one moment — a fresh identity meeting a fresh exit — where the curve peaks.
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)