Rotating vs sticky proxies: how proxy sessions work
Guides · Published November 2, 2026 · 11 min read
Use rotating proxies for requests that don't depend on each other, and sticky sessions for anything where the site has to recognize the same visitor from one request to the next. That's the decision in rotating vs sticky proxies. The parts that go wrong are less obvious: how long a sticky session really lasts, what your HTTP client does with open connections, and how many workers end up sharing one session id.
A rotating proxy gives each connection or request its own exit IP, which fits independent fetches such as search result and product pages. A sticky session adds a session id to your username so the gateway keeps routing you through one exit for a limited window, which fits logins, carts and checkouts. Sticky is best effort, not a guarantee of one IP.
How a rotating proxy gateway picks an exit
You don't connect to residential IPs directly. You connect to one gateway endpoint, proxy.tierproxy.com:823 in our case, and it opens the onward connection through an exit it chooses. In rotating mode your username carries no session id, so the gateway has no reason to prefer one exit over another and picks a fresh one. The dashboard describes this as a new IP for every request.
That phrase hides a detail, and it's the one that confuses people most. A gateway can only choose an exit when it sees something to choose for, and with HTTPS it sees very little. Your client sends a CONNECT request naming the destination host and port. After a 2xx answer the proxy restricts itself to "blind forwarding of data, in both directions, until the tunnel is closed", in the words of RFC 9110 section 9.3.6. Everything inside that tunnel, even a dozen HTTP requests, is encrypted bytes the gateway can't split into requests.
So for HTTPS the practical unit of rotation is the connection, not the request. That's our reading of the protocol, not a documented guarantee, and a gateway can count differently, so check yours (the test near the end takes a few minutes). What matters here is that clients reuse connections on purpose. HTTP/1.1 defaults to persistent connections, "allowing multiple requests and responses to be carried over a single connection". libcurl, according to the curl documentation, "automatically and always tries to reuse connections unless explicitly told not to". A Python requests.Session reuses the underlying TCP connection for repeated requests to the same host, as the requests docs describe.
Diagram of three numbered requests from your code entering one gateway at proxy.tierproxy.com port 823 and leaving through three different sample exit IPs: 203.0.113.17, 203.0.113.42 and 203.0.113.88.
A scraper that keeps one session object and hammers it can push fifty requests down one tunnel and see one IP fifty times. Then it blames the proxy for not rotating.
The fix is boring. If you want a new exit per fetch, open a new connection per fetch: plain requests.get() calls instead of a long-lived session, separate curl runs instead of one process looping, or a Connection: close header, which RFC 9112 section 9.3 says ends the connection after the current response. If you want several requests on one exit, that's what a session id is for, and you shouldn't get it by accident.
What a sticky session is, and what it isn't
A sticky session is a mapping the gateway keeps: this session id goes to this exit. You supply the id and the gateway uses it as a key. Send the same id again and you land on the same exit. Send a new one and you get a new exit, which is also how you force a rotation on demand. The dashboard's generator makes ten characters from lowercase letters and digits, and we'd stay inside that alphabet.
On our gateway the id rides in the username as a sessid. modifier, for example LOGIN__cr.us;sessid.abc123. The dashboard states that each generated entry holds the same IP for about 30 minutes. That's the dashboard's figure, and we'd treat it as a ceiling, not a promise.
Timeline of six numbered requests. Requests 1 to 3 carry session id sessid.abc123 and share exit 203.0.113.17. Requests 4 to 6 carry sessid.x7k2p9 and share exit 203.0.113.42.
The gateway pins an IP, and that's all it pins. Your cookies live in your client, not in the session id. If your client throws its cookie jar away between requests, a sticky exit won't save the login. Keep one requests.Session or one browser context per identity, and one session id with it.
Why a login needs the same IP and the same cookies
HTTP is stateless, so sites bolt state on with cookies. MDN lists session management first among their uses: sign-in status, shopping cart contents, anything else the server has to remember. After a correct login the server answers with a cookie holding a session ID, and the browser sends that cookie back with every later request.
Nothing in that exchange mentions an IP address, so why does the exit matter? Because some sites check. The OWASP session management cheat sheet recommends binding the session ID to client properties "such as the client IP address, User-Agent, or client-based digital certificate", and treats a change in the middle of an established session as a reason to "alert and/or terminate the suspicious session". Plenty of sites don't do this, and the same page admits these checks can't fully defend against session attacks. You can't tell from outside which kind of site you're talking to. A login that works on request one and bounces to the login page on request three is the usual symptom.
Multi-step flows add a second reason. A login or checkout form often embeds an unpredictable CSRF token in the page and compares it when the form comes back, a pattern MDN's CSRF page describes. The token from page one is only useful on the request that follows it, normally alongside the cookie that arrived with it. Split those across two exits and you've handed a site that cares about IP continuity a clean reason to refuse you. A site that doesn't care won't notice.
When rotating is the better choice
Rotate when requests are independent. Search result pages, product pages, public listings and status pages don't care who asked before you, and each response depends only on the URL. For that work a sticky session is worse than useless, because it funnels the whole job through one exit.
The reason is rate limiting. RFC 6585 defines 429 Too Many Requests as the user having sent too many requests in a given amount of time, and it leaves open how the server identifies the user or counts requests. In practice, MDN notes, "rate-limiting restrictions are based on a client's IP", though they can be tied to users or applications when requests are authenticated or carry a cookie.
Do the arithmetic with a made-up budget. Say a site tolerates 30 requests a minute from one address. A job that needs 600 pages a minute has to spread across at least 20 exits, and rotation does that with no bookkeeping on your side. Put the same job behind one sticky id and all 600 arrive from a single IP, twenty times over budget. Real limits vary by site and often by path, and many aren't published, so treat 30 as an illustration.
Rotation isn't permission to ignore the limit. A 429 may carry a Retry-After header, which RFC 6585 allows and RFC 9110 section 10.2.3 defines as either an HTTP-date or a number of seconds. Read it. Spreading requests over many exits changes whose budget you spend, not whether the site wants you to slow down. Follow the site's terms, its robots.txt and the law that applies to you.
Retries cost money here too. Residential traffic is billed per GB, so a loop that retries a heavy page five times pays for five pages. Our bandwidth guide covers what a page really weighs.
Which job needs which mode
Match the mode to whether a later request needs something an earlier one produced. If it does, pin. If it doesn't, rotate.
| Job | Mode | Why | Watch out for |
|---|---|---|---|
| Logging in, then acting as that account | Sticky | The session cookie and the IP stay together | One id per account, never shared |
| Cart, address and payment steps | Sticky | Every step must look like one visitor | The window can end mid-flow, so make the flow restartable |
| Browser automation with a saved profile | Sticky | Cookies and local storage belong to one identity | One browser context per id |
| Search result pages | Rotating | Each query stands alone | Reused connections can pin you by accident |
| Product and listing pages | Rotating | Per-IP budgets get spread across exits | Honor 429 and Retry-After |
| Public status and price monitoring | Rotating | Small, repeated, independent checks | Pick the country with cr., it isn't a session |
| A large download | Sticky | One long connection already stays on one exit | If the exit drops, resume with a range request |
| Checking what a site shows in another country | Rotating | You want many viewpoints | Add state. or city. only when you need them |
Mode and targeting are separate choices. According to the dashboard, city, state, ZIP and ASN filters count bandwidth at 2x, so check it before using them on a large job.
Rotating and sticky usernames, with code
The dashboard builds the targeting string for you once you open an active residential order and pick a country. It's plain text, though, and easy to generate in code. The shape is your login, two underscores, then modifiers separated by semicolons.
LOGIN__cr.us rotating, United States
LOGIN__cr.us;sessid.abc123 sticky
LOGIN__cr.us;state.california;city.losangeles rotating, city-level
LOGIN__cr.us;state.california;sessid.abc123 sticky, state-levelLOGIN and the session ids are placeholders. The dashboard shows your real login.
The cr. modifier is a lowercase country code. state. and city. are lowercase with spaces removed, so New York becomes newyork. zip. and asn. take digits only. The dashboard orders them country, filters, then sessid., and it only enables the state, city, ZIP and ASN fields after you've picked a country. It also warns that those four filters count bandwidth at 2x, so look at your plan before using them on a big job.
Filters narrow where the exit is. They don't change how long you keep it. A city. filter on a username without sessid. still rotates, and a sessid. with no filters still pins. Geo targeting and session handling are separate dials, and mixing them up is a common mistake.
# Rotating: no session id
curl -x http://proxy.tierproxy.com:823 \
--proxy-user 'LOGIN__cr.us:PASSWORD' \
https://api.ipify.org
# Sticky: generate one id and reuse it for every request in the flow
SID=$(openssl rand -hex 5)
curl -x http://proxy.tierproxy.com:823 \
--proxy-user "LOGIN__cr.us;sessid.${SID}:PASSWORD" \
https://api.ipify.orgReplace LOGIN and PASSWORD with the credentials from your dashboard.
--proxy-user keeps the semicolons out of URL parsing. If you'd rather put credentials in the proxy URL, the curl manual says curl URL-decodes the user and password it finds there, so percent-encode anything unusual.
In Python, build the URL from parts and percent-encode both halves. The requests documentation shows proxy authentication as http://user:pass@host:port, and it warns that values set on session.proxies can be overwritten by environment proxy settings, so pass proxies= on each call instead.
import os
import secrets
import time
from urllib.parse import quote
import requests
GATEWAY = "proxy.tierproxy.com:823"
LOGIN = os.environ["TP_LOGIN"] # from the dashboard
PASSWORD = os.environ["TP_PASSWORD"]
def new_session_id():
return secrets.token_hex(5) # 10 chars, lowercase letters and digits
def proxy_url(country="us", session_id=None):
"""No session_id means rotating. With one, the exit is pinned."""
mods = [f"cr.{country}"]
if session_id:
mods.append(f"sessid.{session_id}")
user = f"{LOGIN}__{';'.join(mods)}"
return f"http://{quote(user, safe='')}:{quote(PASSWORD, safe='')}@{GATEWAY}"
def fetch(url, tries=3):
"""Independent page fetch. Every try opens its own connection."""
for attempt in range(tries):
proxy = proxy_url()
r = requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=(5, 30))
if r.status_code not in (429, 503):
return r
retry_after = r.headers.get("Retry-After", "")
time.sleep(int(retry_after) if retry_after.isdigit() else 2 ** attempt)
return rRotating fetches. Credentials come from the environment, not the source file.
fetch() calls plain requests.get, so every try gets its own connection and, in rotating mode, its own exit. It waits for a numeric Retry-After and falls back to exponential backoff when the header is missing or is a date.
Session design for concurrent workers
The rule is one session id per identity, and one identity per worker at a time. An identity is whatever the site treats as one visitor: an account, a cart, a browser profile.
Two things follow. Fifty threads on one id is one visitor making fifty parallel requests from one IP, which is the pattern a per-IP limit exists to stop, and parallel requests on one cookie jar can trip over each other's state anyway. And one id reused across two accounts links them by address for as long as the window lasts, which defeats the point of buying a pool.
Retries need a rule too. After a success, keep the id, because the session is working and the next step needs it. After a failure that looks like a block (a 403, a 429, a login page where content should be), drop the id, the cookie jar and the half-finished flow together, mint a new id and start again from step one. Keeping the cookies while changing the IP presents an old session from a new address, the mismatch OWASP describes. Keeping the IP while retrying just repeats the request that was refused.
from concurrent.futures import ThreadPoolExecutor
def run_flow(steps, attempts=3):
"""One account: one session id, one cookie jar. Start over if the exit is burned."""
for attempt in range(attempts):
proxy = proxy_url(session_id=new_session_id())
proxies = {"http": proxy, "https": proxy}
with requests.Session() as s: # cookies live here, so they die with the id
for method, url in steps:
r = s.request(method, url, proxies=proxies, timeout=(5, 30))
if r.status_code in (403, 429):
break
else:
return r
time.sleep(2 ** attempt)
raise RuntimeError("flow failed on every session id")
flows = [
[("GET", "https://example.com/login"), ("POST", "https://example.com/login")],
[("GET", "https://example.org/login"), ("POST", "https://example.org/login")],
]
with ThreadPoolExecutor(max_workers=len(flows)) as pool:
results = list(pool.map(run_flow, flows))Reuses proxy_url and new_session_id from above. The example.com steps are placeholders for your own flow.
run_flow builds the requests.Session inside the loop so the cookie jar dies with the id. The requests docs say a Session persists cookies and reuses connections, which is what you want inside one identity and the last thing you want across two. Browser automation follows the same rule at a larger scale: one browser context per identity, one id per context, and no reuse after a failure.
Common session mistakes
Most of these come from one assumption, that the IP and the session are the same thing. They aren't. The IP is the gateway's decision, and the session is your client's state.
- Rotating in the middle of a login. Step two leaves from a different address than step one. Use a sticky id from the first request.
- One sticky id for many threads. All of them share one exit and one rate budget.
- A retry loop that hammers the same exit. With a sticky id, the retry goes straight back to the exit that just refused you. Change the id after a block.
- Treating geo targeting as a session.
cr.uspicks a country, and onlysessid.holds an exit. - A shared
requests.Sessionin rotating mode. Connection reuse can keep you on one exit with no session id at all. - Never checking. Print the exit IP at the start and end of a flow. If the two differ, you've learned something your logs should know.
How to check your setup in a few minutes
A public echo endpoint tells you what your client and the gateway actually do, which beats any diagram, ours included. Note your own address on our IP page first so you can tell it from an exit.
# Rotating: expect the printed IP to change between runs
for i in 1 2 3; do
curl -s -x http://proxy.tierproxy.com:823 --proxy-user 'LOGIN__cr.us:PASSWORD' https://api.ipify.org; echo
done
# Sticky: same id every time, expect the same IP unless the exit went away
SID=$(openssl rand -hex 5)
for i in 1 2 3; do
curl -s -x http://proxy.tierproxy.com:823 --proxy-user "LOGIN__cr.us;sessid.${SID}:PASSWORD" https://api.ipify.org; echo
doneEach curl run is its own process and its own connection.
- Run the rotating loop. Three runs make three connections, so expect different addresses.
- Run the sticky loop with one id. The same address each time is the expected result.
- Generate a new id and run it again. A different address confirms the id is what pins the exit.
- In your own client, print the exit IP twice inside one session object. The same address in rotating mode means you reused a connection.
- Log the exit IP at the start and end of every real flow, so a changed exit shows up in your logs and not in a support ticket.
For a longer walk through testing, see how to test a proxy. If you're choosing between traffic types before the session question even comes up, start with the residential plans.
The decision rule is short. If a later request needs something an earlier one produced, whether a cookie, a token or a cart, pin it with a session id and give each identity its own. If every request can stand alone, rotate and open a new connection each time. We'd start with rotating and add sessid. the first time a flow breaks without it.
Frequently asked questions
What is the difference between rotating and sticky proxies?
A rotating proxy sends each connection or request through a different exit IP. A sticky proxy keeps you on one exit for a limited window, keyed by a session id you send. Rotate for independent fetches, pin for logins and multi-step flows.
How long does a sticky session last?
It depends on the provider and the plan. Our dashboard says each generated entry holds the same IP for about 30 minutes, and we'd treat that as a ceiling, since the exit can drop earlier. Check the documentation of any gateway you use before designing around a number.
Can a sticky proxy change IP in the middle of a session?
Yes. A residential exit is a real connection that can go offline or change networks, and a sticky session can't keep an exit that's gone. Your code should detect a logged-out page or a failed connection and restart the flow with a new session id.
What is a proxy session id?
It's a label you choose and send with your credentials so the gateway can map it to one exit. On our gateway it goes in the username as sessid. followed by the id, for example LOGIN__cr.us;sessid.abc123. A new id gets a new exit.
Do rotating proxies change IP on every request?
The dashboard describes rotating mode as a new IP for every request. Connection reuse can change that in practice, because several requests sent down one open connection or HTTPS tunnel leave from the same exit. Open a new connection per fetch if you want a new IP per fetch.
Sources
- RFC 9110: HTTP Semantics (CONNECT, Retry-After)
- RFC 9112: HTTP/1.1 (persistent connections)
- RFC 6585: 429 Too Many Requests
- MDN: Using HTTP cookies
- MDN: 429 Too Many Requests
- MDN: Cross-site request forgery (CSRF)
- OWASP Session Management Cheat Sheet
- everything curl: connection reuse
- curl manual: --proxy and --proxy-user
- Requests documentation: advanced usage
Keep reading
- HTTP vs SOCKS5 proxy: what changes and which to use
- How much proxy bandwidth do you need? Pages per GB and cost
- How to test a proxy: six checks, in order, with commands you can paste