How Rotating Residential Proxies Work: Per-Request vs Sticky vs Timed

Prithwish Nath

Rotating residential proxies change exit IPs three ways — per-request, sticky, or timed. Not one setting. I logged real exit IPs to show how each mode actually behaves.

TL;DR: This article explains what a rotating residential proxy is, and how rotation is actually three genuinely different behaviors - and not a single toggle - using real exit IP logs.


I wrote a previous post on concurrency in data fetching where the conclusion, roughly, was: residential proxies don't fix a start-rate burst, they just make the burst not matter — you spread it across different exit IPs. That's a fine reason to use proxies, but at best, it's a band-aid on a specific problem. I kept seeing rotation described the same way that post warned about concurrency being described -- a one-size-fits-all solution, and off you go. So I did to rotation what I did to concurrency there — stopped assuming and started logging the exit IP after every single call.

I'm using Bright Data for this, mostly because their rotation policy is encoded directly in the auth string you send, which makes it easy to flip modes from a script instead of clicking around a UI.

Here's what I found.

How do rotating residential proxies work?

A rotating residential proxy is a backconnect gateway in front of a pool of real household IPs. Those IPs are assigned by consumer ISPs to devices (phones, laptops, home routers) whose owners opted into sharing idle bandwidth — these are called RESIP nodes. Your client never dials an actual home device. Instead, it authenticates to one stable gateway endpoint - your vendor - then that gateway selects an available peer from the pool, opens an upstream tunnel through that peer, and forwards your request. The target site sees only the peer's exit IP — an ASN that looks like home broadband, not a cloud datacenter.

"Rotation" is when that gateway re-runs peer selection. On each new request (or when a session rule expires), it may keep the same peer or assign a different one. With modern proxies you do not maintain a proxy list in application code; you point at one host:port and pass parameters — usually in the auth string itself — that tell the gateway how to pick: fresh peer every call, hold one peer for a session, or rotate on a timer.

That "how" is not a single toggle. There are three ways this rotation gets configured.

What are the three types of residential proxy rotation?

Starting with a base auth string:

brd-customer-<customer_id>-zone-<zone_name>

We can have three different exit IP rotation behaviors in play here:

  • Per-request — Default behavior - send the base username as-is on every call. No session suffix. The gateway picks a new residential peer each time.
  • Sticky — append -session-<id> and reuse the same <id> for every call in the flow. Same session string → the gateway tries to route you back to the same peer (emphasis on try).
    • brd-customer-abc123-zone-residential-session-cart_flow_001
  • Timed — same -session-<id> mechanism, but your code regenerates the id on a clock (e.g. window_29754325 from floor(now / 60s)). New id → new peer, on your schedule, not the network's idle timer.
    • Inside the 60s bucket: brd-customer-abc123-zone-residential-session-window_29754325
    • After that 60s window, this becomes: brd-customer-abc123-zone-residential-session-window_29754326

"Timed" is not really a third mode, per se. It's basically sticky sessions again, in disguise. The rotation boundary in that one is entirely something your code decides, which means the failure mode when it goes wrong is entirely your fault too — more on that later.

IP "rotation" really means that the gateway - here the 'super proxy' - re-ran its peer selection. Session ids, idle timers, and keep-or-swap decisions all live on that central routing server — not on the residential device.

If you want to run the experiments yourself: sign up for a Bright Data residential zone here, copy the zone username and password from your dashboard. New pay-as-you-go accounts get 5,000 free credits per month, no credit card required, with a hard stop when you run out. Our code adds session suffixes in code after loading .env automatically, naturally.

Comparing Residential Proxy Modes: Per-request vs Sticky vs Timed

I wrote a small script that fires six requests at api.ipify.org — a service whose entire job is echoing your IP back at you — under each mode, and logged what came back.

import os, time
from urllib.parse import quote
import requests

BASE = os.environ["BRIGHT_DATA_PROXY_RESIDENTIAL_USERNAME"]  # brd-customer-…-zone-…
PASSWORD = os.environ["BRIGHT_DATA_PROXY_RESIDENTIAL_PASSWORD"]
HOST, PORT = "brd.superproxy.io", "33335"
GEO_URL = "https://api.ipify.org?format=json"

def build_username(mode, session_id=None, interval_sec=60):
    if mode == "per-request":
        return BASE
    if mode == "timed":
        window = int(time.time()) // interval_sec
        session_id = session_id or f"window_{window}"
    else:
        session_id = session_id or "demo_sticky_01"
    return f"{BASE}-session-{session_id}"

def proxies_for(username):
    url = f"http://{quote(username)}:{quote(PASSWORD)}@{HOST}:{PORT}"
    return {"http": url, "https": url}

def check_exit_ip(username):
    r = requests.get(GEO_URL, proxies=proxies_for(username), verify=False, timeout=60)
    return r.json()["ip"]

for mode in ("per-request", "sticky", "timed"):
    print(f"=== {mode} ===")
    for i in range(1, 7):
        user = build_username(mode)
        print(f"call {i}: {check_exit_ip(user)}")

Look at build_username(). For per-request, it returns the base string, sticky appends a fixed -session- id, and timed appends one that changes when the clock bucket rolls over (we use 60 seconds as the interval here).

We run it (with CSV output) like so:

rotate_demo.py --mode all --flow stateless --count 6

Here's the output.

CallPer-requestStickyTimed (60s window)
1150.228.141.4187.19.243.201176.148.76.94
2223.184.172.107187.19.243.201176.148.76.94
391.65.135.214187.19.243.201176.148.76.94
4138.97.165.58187.19.243.201176.148.76.94
5186.101.186.6187.19.243.201176.148.76.94
6138.97.184.197187.19.243.201176.148.76.94

Everything was exactly as expected:

  • Per-request burns through a new device every call.
  • Sticky holds one IP the entire time.
  • Timed also holds the exit IP — but only because all six calls happened to land inside the same 60-second bucket and shared a session id (window_29754325) without me doing anything clever.

Timed mode is remarkably similar to proper Sticky, but if you gave it a longer window, it would stop looking like sticky's twin. This is the whole point of the next section.

Why do sticky residential proxy sessions rotate early?

To its credit: residential sticky sessions persist for up to about five minutes of idle time, and if you need longer than that you're supposed to send a keep-alive roughly every 30 seconds. "Up to" is the part I wanted to stress-test.

So what would happen if we used one session id for six calls, deliberately uneven gaps, nothing else touched in between?

import time

SESSION = "drift_test_01"
USERNAME = f"{BASE}-session-{SESSION}"  # same string on every call
gaps = [0, 30, 90, 200, 310, 20]       # seconds to wait before each call

last_ip = None
for gap in gaps:
    if gap > 0:
        time.sleep(gap)
    ip = check_exit_ip(USERNAME)  # same helper as above
    rotated = last_ip is not None and ip != last_ip
    print(f"+{gap}s -> {ip} {'(ROTATED)' if rotated else '(same peer)'}")
    last_ip = ip

We run it like so:

py -3 rotate_demo.py --mode sticky --gaps 0,30,90,200,310,20

That command takes about eleven real-world minutes to run. And here's what that gives us.

Gap since last callExit IPRotated?
first call186.107.196.237session established
+30s186.107.196.237no
+90s186.107.196.237no
+200s45.182.77.171yes
+310s103.199.76.186yes
+20s103.199.76.186no

I expected the rotation to show up around +310s, comfortably past five minutes. It showed up at +200s — a bit over three minutes in.

Which sounds like a bug report, but not really. "Up to five minutes" is a ceiling, not a promise, and a 3.3-minute gap ending a session is entirely inside spec. There's also a second, equally plausible explanation for the exact same result: the peer device simply went offline. From where I'm sitting those two causes look identical — both show up as a new IP, no error, nothing in the logs.

Your takeaway here should be this:

The session id on call four was byte-for-byte the same string as call one. The IP still changed, without warning, and earlier than the documented ceiling.

That's the shape of the bug that reaches production. Log in, browse for a bit, hit a checkout step that waits on a queue or a retry with backoff — and somewhere in there the idle window closes, you get switched to a different residential device. That's a genuine footgun to watch out for.

Sticky sessions can also end two other ways, both downstream of the same "borrowed device" fact. A peer can get recycled by the pool even under constant traffic — min_ttl is the documented lever for that. Or the device can just go offline, and here's the part that actually matters operationally: without -const, a dead peer can come back as a 502 on one call and then a brand new peer on the retry, which your code will happily treat as success. With -const set, the failure stays a failure, and at least you get to decide what to do about it.

Do concurrent requests share one sticky exit IP?

Again, we make five requests with same session id...but this time, in parallel:

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

USERNAME = f"{BASE}-session-parallel_test_01"  # one session, five at once

def timed_check(i):
    t0 = time.perf_counter()
    ip = check_exit_ip(USERNAME)
    ms = (time.perf_counter() - t0) * 1000
    return i, ip, ms

with ThreadPoolExecutor(max_workers=5) as ex:
    futs = [ex.submit(timed_check, i) for i in range(1, 6)]
    ips = []
    for fut in as_completed(futs):
        i, ip, ms = fut.result()
        ips.append(ip)
        print(f"[sticky] call {i}: {ip}  ({ms:.0f}ms)")

print("unique IPs:", len(set(ips)))

Full run:

py -3 rotate_demo.py --mode sticky --parallel 5

And here's what we get back.

[sticky] call 2: 144.124.199.155  (7518ms)
[sticky] call 1: 144.124.199.155  (7806ms)
[sticky] call 3: 144.124.199.155  (7957ms)
[sticky] call 4: 144.124.199.155  (7964ms)
[sticky] call 5: 144.124.199.155  (8214ms)
unique IPs: 1

Looks good -- we seem to have used only one IP for all five calls. Good news if you wanted a controlled comparison. Decidedly less good if you look at the timing: my earlier sequential sticky calls came back in 1.8 to 5.6 seconds each. These, sharing one peer, were all between 7.5 and 8.2 seconds.

The takeaway? Concurrency here doesn't buy you throughput — it builds a queue in front of one person's home router. If you need continuity and volume, the fix isn't more parallel requests on one session. It's more sessions, each with its own id, each parked on its own peer.

When does per-request rotation break a scraping session?

Take a three-step flow — step one logs in, steps two and three need to be recognized as the same visitor — and run it once under each mode.

def run_stateful_flow(mode, interval_sec=60):
    """Three sequential probes; flow holds if exit IP stays on step 1's peer."""
    first_ip = None
    steps = []
    for step in range(1, 4):
        user = build_username(mode, interval_sec=interval_sec)
        ip = check_exit_ip(user)
        if step == 1:
            first_ip = ip
            ok = True
        else:
            ok = ip == first_ip
        steps.append((step, ip, ok))
        print(f"[{mode}] step {step}: {ip}  {'OK' if ok else 'FAIL'}")
    success = all(ok for _, _, ok in steps)
    print(f"-> flow success: {success}\n")
    return success

for mode in ("per-request", "sticky", "timed"):
    run_stateful_flow(mode)  # timed uses 60s window via build_username

Full run (all three modes + tables):

py -3 rotate_demo.py --mode all --flow stateful

Output:

ModeStep 1Step 2Step 3Flow
Per-request45.232.185.216213.230.76.190201.77.144.168FAIL
Sticky103.199.76.186103.199.76.186103.199.76.186SUCCESS
Timed (60s)49.47.199.3349.47.199.3349.47.199.33SUCCESS

Per-request didn't fail because anything went wrong. It failed because it did precisely what it advertises: a fresh device on every call. Point that at a stateful flow and a cookie from step one is worthless by step two — not an edge case, the default outcome.

I've started thinking about rotation policy the way I think about a database isolation level: it's a correctness setting, not a performance dial. Per-request on a login flow is a bug you configured yourself. Sticky across ten thousand independent page fetches isn't extra safety, it's just funneling all that traffic through one overloaded phone.

The timed column above passed, and I don't want that to read as "timed is safe." It passed because a 60-second window happened to outlast a three-step flow, which is a coincidence of configuration, not a property of the mode. Shrink the window to one second, same flow, same script:

Break timed on purpose with a 1s window:

py -3 rotate_demo.py --mode all --flow stateful --break-timed

Output:

ModeStep 1Step 2Step 3Flow
Per-request122.168.67.10541.39.141.22288.163.186.153FAIL
Sticky144.124.199.155144.124.199.155144.124.199.155SUCCESS
Timed (1s)102.180.60.24580.76.58.1312.85.13.248FAIL

Just with one number changed, we've broken timed rotation. That's the tradeoff of timed rotation: you own the rotation boundary, which is great right up until your interval is shorter than your slowest step.

When should you use per-request vs sticky vs timed rotation?

TL;DR: Use Per-request for a bunch of independent fetches where nothing needs to look like the same visitor twice. Use Sticky for a short flow that needs one consistent identity, with the idle window designed in from day one — keep-alives or min_ttl, not hope. And finally, use Timed when you want the rotation boundary on your own clock, set to something comfortably longer than your slowest step, which you should measure before you pick a number.

Here's a good decision table for your perusal:

Per-requestStickyTimed
IP changesEvery callOn idle gap, peer recycle, or device dropWhen your interval window rolls over
Who ends the windowThe poolMostly the network, partly youJust you
Concurrent requestsEach gets its own peerAll share one peerAll share the window's peer
Best forIndependent fetches at volumeShort flows needing continuityBounded continuity on a schedule
How it breaksUsing it on a stateful flowAssuming sticky means lockedIf interval is shorter than the task

Is a sticky proxy session a guaranteed IP?

A sticky session is frequently misunderstood -- it's actually a routing preference. Not a reservation.

The reason none of it comes with a guarantee is the same reason residential proxies are worth using at all: the IP belongs to an actual person's device, borrowed while it's idle.

You can ask for more continuity, and ask harder with -const or min_ttl, but it's still a request, not a claim on hardware you own.

The nice part is that none of this has to stay theoretical — you can verify all of this yourself. It'll take you an afternoon of logging exit IPs, not a claim you have to take on faith from a docs page.


Frequently Asked Questions (FAQ)

What is the difference between residential and datacenter rotating proxies?

Residential rotation swaps you through real consumer devices; datacenter rotation swaps you through servers in a provider's rack. Both can rotate per request or hold a session, but the exit IP's reputation profile is completely different — residential IPs look like home broadband to most anti-bot stacks, datacenter IPs look like cloud/hosting. Rotation policy (per-request vs sticky) is orthogonal to IP type: you can misconfigure sticky on either kind. This article only measured residential behavior; datacenter idle windows and session limits differ by product tier.

Legality is two questions: how the IPs were sourced, and what you do with the egress. Bright Data's ethical data collection checklist frames the first half — residential and mobile IPs should come from peers who gave informed consent, can opt out, and are only used when their device is idle. That is the provider's obligation. Yours is the second half: Terms of Service, robots.txt, copyright, and privacy rules on the targets you hit still apply no matter which rotation mode you pick. Per-request, sticky, and timed only change which IP the site sees on each call; they do not change whether the scrape itself is allowed.

Can websites detect rotating residential proxies?

Yes — rotation affects which signals move, not whether detection runs at all. Targets still see TLS fingerprints, header order, request timing, JavaScript execution, and behavioral patterns. Per-request rotation can make IP-based rate limits noisier to correlate, but it can also increase suspicion when a single cookie or login session hops across unrelated ASNs. Sticky sessions reduce IP churn but concentrate other signals (same device class, same geo, same timing) on one peer. Detection is multi-factor; rotation is one knob.

Can two consecutive per-request calls return the same exit IP?

This depends on the size of your proxy pool — but technically, yes, it's possible. Per-request rotation means no session memory, not a mathematical guarantee of uniqueness. Each call asks the super proxy for the next available peer from the pool; with millions of residential devices in rotation, a repeat city or even a repeat IP is possible by chance. The only way to tell the difference is to log the exit IP after every call — geo labels alone are misleading because two different peers can share a city. If you need proof that two requests were different visitors, compare IP octets, not location strings. Sticky and timed modes exist precisely when you need continuity instead of independence.

Can you rotate proxies by country or city and still use sticky sessions?

Yes — geo parameters and session parameters are separate suffixes on the same username string. You can append country or city targeting alongside -session-<id> so a sticky flow stays on one peer within a chosen geography. Geo accuracy and session continuity are independent guarantees: a sticky session can still drift peers or end early on idle, and geo targeting can silently fall back to region-level routing if city-level peers are unavailable. This article did not measure geo accuracy; it only logged exit IPs on a single unconstrained zone.


I'm deliberately not covering city-level targeting benchmarks, residential-vs-datacenter head-to-head runs, or any ban-rate number I can't personally reproduce.

Comments

Loading comments…