← All articles

Session Persistence in Web Scraping: Reuse Cookies to Scrape Faster

If your scraper spins up a headless browser for every single page, you are paying for the slowest possible path on every request. A real browser launch costs hundreds of milliseconds to a few seconds, burns memory, and does the same login and challenge-solving work over and over again.

There is a better pattern: warm a session once in a browser, capture the cookies, and hand them to a fast HTTP client for the bulk of your requests. This is session persistence, and it is one of the highest-leverage optimizations you can make in a scraping pipeline. Done right, it cuts your per-request cost by an order of magnitude and makes your traffic look more consistent, not less.

This guide explains what a session actually is, how to move one from a browser to a plain HTTP client, why short-lived cookies like __cf_bm force you to refresh, and how to keep the whole thing stable in production.

What a “Session” Really Is

When people say “stay logged in” or “keep the session alive,” they usually mean cookies. But a session is more than a login token. On a modern site protected by an anti-bot layer, a working session is a bundle of things that have to stay consistent:

  • Authentication cookies. The token that proves you are logged in (often something like access_token, session_id, or a signed JWT in a cookie).
  • CSRF tokens. Sometimes in a cookie, sometimes in a header or a hidden form field.
  • Bot-management cookies. These are the ones people forget. Cloudflare sets __cf_bm (a short-lived bot-management cookie, typically valid for around 30 minutes) and, after a challenge, cf_clearance. Other vendors set their own equivalents.
  • A consistent client identity. The same User-Agent, the same broad header set, ideally the same IP address, and a TLS fingerprint that matches a real browser.

The trap is treating a session as “just the login cookie.” You copy the auth cookie into requests, it works for a few minutes, then everything starts returning 403s or redirecting to a challenge page. That is almost always a bot-management cookie expiring, or a TLS fingerprint mismatch, not your login dying.

The Hybrid Pattern: Warm in a Browser, Run in an HTTP Client

The core idea is a division of labor:

  1. Use a real (or headless) browser to establish the session. The browser handles JavaScript challenges, the login flow, and any client-side token generation. This is the expensive part, and you do it rarely.
  2. Export the cookies. Pull every cookie the browser collected, not just the login one.
  3. Replay with a fast HTTP client. For the actual data pages, use a lightweight client that reuses those cookies. No browser, no JavaScript engine, just HTTP.

The catch is fingerprinting. A plain requests call has a Python TLS signature that anti-bot systems can spot instantly, so the cookies you worked hard to mint get rejected. The fix is to use an HTTP client that impersonates a browser’s TLS and HTTP/2 fingerprint. In Python, curl_cffi does exactly this.

Step 1: Warm the Session in Playwright

from playwright.sync_api import sync_playwright

def warm_session(url: str) -> dict:
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context()
        page = context.new_page()

        # Load the site so it can run its JS and set its cookies.
        page.goto(url, wait_until="networkidle")

        # If there is a login, do it here so the auth cookies get set.
        # page.fill("#email", "..."); page.fill("#password", "...")
        # page.click("button[type=submit]"); page.wait_for_load_state("networkidle")

        cookies = context.cookies()
        user_agent = page.evaluate("() => navigator.userAgent")
        browser.close()

    # Flatten into a name -> value dict for the HTTP client.
    jar = {c["name"]: c["value"] for c in cookies}
    return {"cookies": jar, "user_agent": user_agent}

Notice that you capture the User-Agent too. The HTTP client must send the same one, or the session looks inconsistent.

Step 2: Replay With curl_cffi

from curl_cffi import requests as cffi

def fetch_with_session(url: str, session: dict) -> str:
    resp = cffi.get(
        url,
        cookies=session["cookies"],
        headers={"User-Agent": session["user_agent"]},
        impersonate="chrome",  # match a real Chrome TLS/HTTP2 fingerprint
        timeout=30,
    )
    resp.raise_for_status()
    return resp.text

Now you can fire hundreds of these calls per warmed session. Each one is a normal HTTP request that carries a browser-grade fingerprint and a full cookie jar. No browser overhead per page.

Here is where most naive implementations fall apart. Some cookies are durable (a login token might last for days), but the bot-management cookies are deliberately short-lived. Cloudflare’s __cf_bm is the classic example: it is designed to expire in roughly 30 minutes so that a captured session cannot be replayed forever.

So a session is not a one-time capture. It is a living thing you have to top up. The durable cookies (your login) stay valid; the short-lived ones need re-minting. The clean model is:

  • Keep the durable cookies (auth) in long-term storage.
  • Periodically re-open a browser with those durable cookies already injected, let the site hand you fresh short-lived cookies, and push the updated jar back to your fast client.

Injecting the durable cookies into a fresh browser context before navigating means you skip the full login every time. You only pay for the cheap “reload and collect fresh tokens” step.

def refresh_session(url: str, durable_cookies: list[dict]) -> dict:
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context()
        context.add_cookies(durable_cookies)  # inject the long-lived auth cookies
        page = context.new_page()
        page.goto(url, wait_until="networkidle")  # site mints fresh short-lived cookies
        cookies = context.cookies()
        browser.close()
    return {c["name"]: c["value"] for c in cookies}

Run this on a timer that is comfortably shorter than the shortest cookie lifetime. If __cf_bm lasts about 30 minutes, refreshing every 10 to 15 minutes keeps you safely inside the window.

Detecting a Stale Session

Do not rely only on the clock. Sites change TTLs, and a session can die early. Add a cheap health check so your client knows when to trigger a refresh instead of silently scraping garbage.

Signals that a session has gone stale:

  • A 403 or 429 where you previously got 200.
  • A redirect to /login, /challenge, or a Cloudflare interstitial.
  • A 200 response whose body is the challenge page, not your data (an HTTP 200 soft block).
  • Your expected CSS selector or JSON key suddenly missing.
def looks_blocked(resp) -> bool:
    if resp.status_code in (401, 403, 429):
        return True
    body = resp.text.lower()
    markers = ["just a moment", "verify you are human", "cf-challenge", "enable javascript"]
    return any(m in body for m in markers)

When looks_blocked fires, requeue the URL, trigger a refresh, and retry. Treat a single stale detection as a trigger, not a failure.

Production Details That Actually Matter

A few things separate a demo from a pipeline that runs for weeks:

  • Keep the IP consistent with where the cookies were minted. Anti-bot systems bind sessions loosely to the network they were created on. If you warm a session on one IP and replay it from a wildly different one, you invite a challenge. Where you use proxies, keep a session pinned to a stable exit for its lifetime.
  • Match the whole header profile, not just the User-Agent. Header order, Accept-Language, Sec-Ch-Ua hints, and Accept-Encoding should look like the browser you warmed with. curl_cffi’s impersonate handles most of this for you.
  • Do not overload one session. A single browser session hammering thousands of requests per minute is its own tell. Spread load across several warmed sessions and rotate between them.
  • Persist cookies to disk or a small database. If your process restarts, you do not want to redo every login. Store the durable cookies, reload them, and refresh.
  • Rotate identities if you run several accounts. Keep each account’s durable cookies separate and pick one at random per batch. This spreads any per-account rate limits, though it does not change your underlying IP reputation.

That last point is worth being honest about. Session reuse solves speed and per-account throttling. It does not fix a bad IP. If your exit addresses are already flagged, no amount of cookie hygiene will save you, and that is where a managed unblocking layer earns its keep.

Frequently Asked Questions

Do I still need a browser if I use session persistence? Yes, but rarely. The browser establishes and refreshes the session; the fast HTTP client does the bulk of the fetching. You go from one browser launch per page to one every 10 to 30 minutes.

Why does my copied cookie work in the browser but not in requests? Almost always a fingerprint mismatch. Plain requests has a Python TLS signature that anti-bot systems flag on sight. Use curl_cffi with impersonate, or another client that mimics a browser fingerprint, and send the same User-Agent.

What is the __cf_bm cookie and why does it keep expiring? __cf_bm is Cloudflare’s bot-management cookie. It is intentionally short-lived (around 30 minutes) so captured sessions cannot be replayed indefinitely. Refresh your session inside that window.

Can I share one session across many machines? You can, but keep them behind the same or similar exit IP and do not exceed sensible request rates. Spreading one session across many IPs at once is a common way to get it invalidated.

Wrapping Up

Session persistence is mostly about respecting how sessions actually work: a bundle of durable and short-lived cookies, tied to a consistent client identity and network. Warm once in a browser, replay with a fingerprint-matched HTTP client, refresh before the short-lived cookies expire, and watch for soft blocks so you re-warm on time.

If you would rather not run the browser fleet, cookie refresher, and proxy rotation yourself, that is exactly what ScrapeUnblocker handles behind a single API call: it manages the session, fingerprint, and unblocking so you just request a URL and get the page back. You can read the developer docs to see how it fits into an existing pipeline. Either way, the principle is the same: do the expensive work once, and reuse it.

Try ScrapeUnblocker free

95%+ success rate · from 0.55€ per 1,000 calls · 500 free requests on signup.

Try it free → See pricing