๐ŸŽ‰ Premium Proxies ยท 24-Hour Free TrialClaim Now
Comparisons

curl_cffi vs tls-client vs hrequests (2026)

curl_cffi vs tls-client vs hrequests: there are only two engines here. Compare TLS and HTTP/2 fingerprint control, profile freshness, async model and install.

S SparkProxy 2 27 min read
Share

curl_cffi vs tls-client is a real engine decision, but tls-client vs hrequests is not: curl_cffi binds lexiforest/curl-impersonate while both of the others wrap the same Go library, bogdanfinn/tls-client, so what actually separates the three is how each one delivers and refreshes its native layer.

Almost every article on this subject treats it as a three-way engine shootout and compares JA3 support, which all three have had for years. The useful question is different. The fingerprint surface a defender reads is layered, your library covers only two of those layers, and the engine version that matters is compiled into a shared object that pip install --upgrade can't touch.

curl_cffi vs tls-client vs hrequests at a glance

All figures read on 2026-10-09 from PyPI, the GitHub API and each project's source.

Dimensioncurl_cffi 0.16.3tls-client 1.0.1hrequests 0.9.2
Native enginelexiforest/curl-impersonate (patched curl on BoringSSL)bogdanfinn/tls-client (Go, uTLS fork)the same Go library, pinned to v1.7.9
Latest release2026-09-022024-02-022024-12-01
Last commit2026-10-082024-02-022024-12-01
Newest Chrome target`chrome150``chrome_120``chrome_131`
Newest Firefox target`firefox147``firefox_120``firefox_132`
Safari and iOS`safari2601`, `safari260_ios``safari_16_0`, `safari_ios_16_0`none
Edge`edge99`, `edge101` (2022 vintage)nonenone
Mobile, app and other profiles`chrome99_android`, `chrome131_android`, `tor145`okhttp4 Android 7 to 13 plus 14 app profilesnone
Extension permutation defaultset per impersonate targetoffon
HTTP/2 fingerprint control`akamai=`, `extra_fp`, raw CURLOPTsfull structured setsame structured set
HTTP/3yes, `http_version="v3"`no control exposedno control exposed
Concurrencyreal asyncio `AsyncSession`sync onlygevent `map` / `imap`
Response cachingyes, on disk, sync session onlynono
Wheel shapeper-platform cp310-abi3, musl includedone 39.4 MB `py3-none-any`60 KB, binary fetched at first import
LicenceMITMITApache-2.0 in metadata, MIT per GitHub
PyPI downloads, 30 days32,516,027653,84915,646
GitHub stars6,6848181,026

Download counts come from pypistats, which republishes PyPI's own dataset and counts mirrors and CI runs. Read it as relative adoption, not headcount.

There are two engines here, not three

Start with the dependency graphs, because they decide most of what follows.

curl_cffi is a cffi binding to lexiforest/curl-impersonate, an active fork rather than the original. The fork's README lists what it added: ECH, ZSTD, X25519Kyber768 and X25519MLKEM key shares, extra Akamai HTTP/2 options aimed at Safari, extension reordering, GREASE toggles, and HTTP/3 with QUIC fingerprints. It also names the TLS stack underneath, a patched BoringSSL, Google's own library. The original repo (7,104 stars) hasn't been pushed since 2024-07-18. It isn't archived and carries no deprecation notice, it just stopped moving. The fork (2,804 stars) shipped v2.2.3 on 2026-09-16 on curl 8.22.0.

Python tls-client is a ctypes binding over bogdanfinn/tls-client, a Go project built on bogdanfinn/fhttp and a fork of refraction-networking/utls. Its own __init__.py credits bogdanfinn directly.

hrequests is the one people get wrong. It has no Go TLS layer of its own. Its bridge/go.mod requires github.com/bogdanfinn/fhttp v0.5.29 and github.com/bogdanfinn/tls-client v1.7.9, with bogdanfinn/utls v1.6.2 indirect, and the README says plainly that requests go through bogdanfinn's tls-client to spoof the TLS fingerprint.

So the handshake bytes tls-client and hrequests put on the wire come from one codebase at two pinned versions. Choosing between them is not a fingerprint-quality decision.

pip install "curl_cffi>=0.16.3"   # cp310-abi3 wheels, nothing to build
pip install tls-client            # one 39.4 MB wheel, 7 prebuilt native libraries inside
pip install hrequests             # 60 KB; the Go binary downloads on first import
Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Layer 1: the TLS ClientHello

The ClientHello carries the TLS version, cipher suites, extension list, supported groups and EC point formats, and JA3 hashes those five fields in order. The mechanics are in What Is TLS Fingerprinting (JA3/JA4), so here is only what differs between the libraries. A stock client emits whatever its own TLS stack produces and gives you no handle on it, which is the dividing line between these three and the ordinary tools in cURL vs Python Requests.

curl_cffi exposes ja3= for the hashed fields and extra_fp for everything the hash misses: tls_min_version, tls_grease, tls_permute_extensions, tls_cert_compression, tls_signature_algorithms, tls_delegated_credential and tls_record_size_limit.

from curl_cffi import requests
from curl_cffi.requests import ExtraFingerprints

API_KEY = "YOUR_API_KEY"

r = requests.get(
    "https://scrape.sparkproxy.io/api/v1",
    headers={"X-API-Key": API_KEY},
    params={"url": "https://www.sparkproxy.io/", "render_js": "false"},
    impersonate="chrome",
    extra_fp=ExtraFingerprints(tls_grease=True, tls_permute_extensions=True),
)
print(r.status_code, r.headers.get("X-Credits-Used"))

tls-client and hrequests expose the equivalent fields as ja3_string, supported_signature_algorithms, supported_versions, key_share_curves, cert_compression_algo and random_tls_extension_order.

import tls_client

session = tls_client.Session(client_identifier="chrome_120",
                             random_tls_extension_order=True)
r = session.get("https://scrape.sparkproxy.io/api/v1",
                headers={"X-API-Key": "YOUR_API_KEY"},
                params={"url": "https://www.sparkproxy.io/", "render_js": "false"},
                allow_redirects=True)
print(r.status_code)

That random_tls_extension_order=True isn't decoration. Chrome has permuted its TLS extension order since version 110, and Python tls-client defaults the flag to False in sessions.py, while hrequests sets it to True by default in client.py. Same engine, opposite out-of-the-box behaviour, and the one that looks like the canonical binding ships the stale default.

One thing to stop looking for: curl_cffi won't accept a JA4 string. Its docs are blunt about why, answering its own question with "Yes, by supporting the entire packet, ja4 is just part of it" and "No, we do not accept ja4 as a input string, because it's a hash and not parsable". Feed it the components instead.

On raw TLS coverage the two engine families are close. Both expose extension order, GREASE, certificate compression and signature algorithms. No primary source publishes a measured per-profile comparison against a real browser capture, so treat any claim that one engine is "more complete" at this layer, including from this post, as unproven. The verifiable differences are everywhere else.

Layer 2: the HTTP/2 fingerprint

This is where most articles stop. Once ALPN negotiates h2, your client opens with a connection preface, a SETTINGS frame, a WINDOW_UPDATE and possibly PRIORITY frames, then emits pseudo-headers in an implementation-specific order. Akamai's 2017 research defined the compact format most vendors now hash, and HTTP/2 vs HTTP/1.1 for Web Scraping walks through the frame-level detail. A perfect JA3 paired with Go's SETTINGS order is a contradiction no real browser produces.

curl_cffi takes this as one pipe-delimited string, matching the Akamai layout of SETTINGS pairs, WINDOW_UPDATE, PRIORITY tuples and pseudo-header order:

from curl_cffi import requests

r = requests.get(
    "https://scrape.sparkproxy.io/api/v1",
    headers={"X-API-Key": "YOUR_API_KEY"},
    params={"url": "https://www.sparkproxy.io/", "render_js": "false"},
    ja3="771,4865-4866-4867-49195-49199,0-23-65281-10-11-35-16-5-13,29-23-24,0",
    akamai="1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
)

The same axes are also reachable as raw libcurl options that stock curl does not have: CURLOPT_HTTP2_SETTINGS, CURLOPT_HTTP2_WINDOW_UPDATE, CURLOPT_HTTP2_PSEUDO_HEADERS_ORDER, plus CURLOPT_SSL_ENABLE_ALPS, CURLOPT_SSL_SIG_HASH_ALGS, CURLOPT_SSL_CERT_COMPRESSION, CURLOPT_SSL_ENABLE_TICKET and CURLOPT_SSL_PERMUTE_EXTENSIONS.

The uTLS pair takes the same information as structured Python, easier to read and to diff:

import tls_client

session = tls_client.Session(
    client_identifier="chrome_120",
    h2_settings={"HEADER_TABLE_SIZE": 65536, "MAX_CONCURRENT_STREAMS": 1000,
                 "INITIAL_WINDOW_SIZE": 6291456, "MAX_HEADER_LIST_SIZE": 262144},
    h2_settings_order=["HEADER_TABLE_SIZE", "MAX_CONCURRENT_STREAMS",
                       "INITIAL_WINDOW_SIZE", "MAX_HEADER_LIST_SIZE"],
    pseudo_header_order=[":method", ":authority", ":scheme", ":path"],
    connection_flow=15663105,
    header_order=["accept", "user-agent", "accept-encoding", "accept-language"],
)

hrequests accepts that identical parameter set through its TLSClient dataclass in client.py. Its README lists only a subset, so read the dataclass, not the docs.

Functionally this axis is a draw. The split is format: curl_cffi takes one string you can copy from a capture tool verbatim, while the Go-backed pair makes you name every SETTINGS key and its position. Both preserve order, which is the part that matters, because the Akamai format encodes the order the parameters were written in rather than a sorted set.

Layer 3: the part no library can spoof for you

Above the handshake, you are on your own. Header order, header casing, cookie handling, referrer chains, request pacing and navigation order all come from your code. All three libraries expose a header_order parameter, and none can know that a real Chrome tab fetched the CSS before the XHR.

One default deserves five seconds of your time. Python tls-client sets its session User-Agent to f"tls-client/{__version__}", which is the literal string tls-client/1.0.1 on the current release. A flawless Chrome handshake carrying that header identifies itself in the first request, so override session.headers["User-Agent"] before anything else. Rotating it carelessly makes things worse, which How to Rotate User Agents for Web Scraping covers. hrequests ships browserforge pinned to 1.1.2 for coherent header sets, the only in-package implementation of the three.

There is also a hard ceiling, and curl_cffi's maintainers state it plainly. Their impersonation FAQ answers "Can I change JavaScript fingerprints with this library?" with "No, you can not": those fingerprints come from browser APIs, and curl_cffi is a Python binding to a C library with no JavaScript runtime under the hood. The same reasoning covers all three.

One widely quoted piece of evidence for that ceiling does not survive checking. The line "TLS fingerprinting alone isn't enough for modern bot protection", followed by the names Akamai, DataDome, Kasada and Incapsula, appears word for word in curl_cffi's README, in lexiforest/curl-impersonate's README and in bogdanfinn/tls-client's README. That is not three maintainers reaching one conclusion. It is a single sponsored block from the vendor Hyper Solutions, carried in all three, down to the shared utm_campaign strings on its links. The technical point holds on its own, but read it as advertising rather than testimony.

Profile freshness, and why it is inverted

Pick a profile and the library freezes the handshake bytes it was built with, so a profile table doubles as a release-date table.

Browsercurl_cffi 0.16.3tls-client 1.0.1hrequests 0.9.2Go upstream v1.16.0
Chrome`chrome150``chrome_120``chrome_131``chrome_152`
Firefox`firefox147``firefox_120``firefox_132``firefox_148`
Safari iOS`safari260_ios``safari_ios_16_0`none`safari_ios_26_0`
Edge`edge101`nonenonenone
Other`tor145`, Android ChromeOpera, okhttp4, 14 app profilesnone`brave_146`, cloudscraper
Default when unsetalias `chrome` to `chrome150``chrome_120`newest supported, so `firefox_132``Chrome_150`

The Go library underneath both Python wrappers is alive. Its README advertises HTTP/3 fingerprinting, Chrome-style protocol racing between H2 and H3, WebSocket connections that keep their TLS fingerprint, and certificate pinning. v1.16.0 landed on 2026-09-02, added chrome_150 and chrome_152 with their PSK variants, HTTP/3 through SOCKS5 proxies, a session-ticket switch and ML-DSA signature verification, and moved the default profile from Chrome 146 to Chrome 150. Of that list the Python wrappers surface certificate pinning, which both do expose. The new profiles, the protocol racing, the H3 work and the WebSocket path stay out of reach.

Now the inversion. The thin, direct binding looks canonical and is the more out of date of the two. Python tls-client vendors a Go shared object whose profile table stops at chrome_120, 32 Chrome majors behind upstream's chrome_152, with extension permutation off. hrequests, despite no commits since 2024-12-01 and an older pinned Go release, reaches chrome_131 and firefox_132 and turns permutation on. Packaging intuition points the wrong way.

No pip install --upgrade closes either gap, because the version that matters is compiled into a .so rather than resolved by the dependency solver. That is the real decision rule for 2026: judge by how the native layer is delivered, not by which README reads better.

curl_cffi is the only one of the three with a refresh path that skips the Python release cycle. Since v0.15.1 the bundled CLI pulls newer fingerprints in place:

curl-cffi get tls.browserleaks.com/json --impersonate chrome   # inspect what you emit
curl-cffi update                                               # refresh profiles, same package version

Chrome, Safari and Firefox updates are free. Other browser types sit behind the maintainers' paid impersonate.pro plan.

One detail saves confusion when you read curl_cffi's target list: skipped numbers such as chrome122 and chrome135 aren't gaps. The docs state that versions are added only when the fingerprint actually changes, and chrome122 is the example they use, so you impersonate the missing ones with the previous target plus your own headers. The same docs record past errors in their footnotes, with two separate fixes: safari153 and safari155 carried wrong HTTP/2 fingerprints until v0.6.0, and firefox144 carried a wrong User-Agent until v0.15.0. Pin your library version when a profile matters.

How the native layer reaches your machine

Three very different answers, and this is where a container build either works or doesn't.

curl_cffi publishes 21 wheels for 0.16.3: macOS x86_64 and arm64, manylinux_2_17 x86_64, aarch64 and i686, manylinux armv7l and riscv64, musllinux_1_2 x86_64 and aarch64, win_amd64 and win_arm64, a cp314t free-threaded set, and Android arm64-v8a. The main wheels are cp310-abi3, so one file covers Python 3.10 and up. Linux wheels run 12 to 13 MB, Windows 1.6 to 1.9 MB.

tls-client ships one wheel, tls_client-1.0.1-py3-none-any.whl, 39.4 MB, holding seven prebuilt native libraries under tls_client/dependencies/: two Windows DLLs, two macOS dylibs, and tls-client-amd64.so, tls-client-arm64.so and tls-client-x86.so. Because the tag is py3-none-any, pip applies no platform or libc gating at install time. Its loader picks one at import, and the Linux branch has a problem:

# tls_client/cffi.py, Linux branch, in source order
if machine() == "aarch64":
    file_ext = "-arm64.so"
elif "x86" in machine():      # platform.machine() returns "x86_64" on 64-bit Linux
    file_ext = "-x86.so"
else:
    file_ext = "-amd64.so"    # never reached on x86_64

# check what your own box resolves to:
#   python -c "import platform;m=platform.machine();print(m,'x86' in m)"
#   x86_64 True   ->  loads tls-client-x86.so

"x86" in "x86_64" is True, so the second branch always wins and tls-client-amd64.so is unreachable on 64-bit Linux. The project's own README designates that file as the Linux Alpine / AMD64 binary, under a PyInstaller note, and tls-client-x86.so as the Ubuntu one. We read the loader, listed the wheel to confirm both files ship inside it, and read the README labelling. We didn't run it on Alpine or inspect the libraries' libc linkage, so the honest claim is about selection rather than a guaranteed crash: the binary the README nominates for Alpine is never chosen automatically there.

hrequests takes a third route. Its wheel is 60 KB and carries no binary at all. On first import, hrequests/cffi.py calls the GitHub releases API, finds the asset matching hrequests-cgo---, downloads it into hrequests/bin/, and deletes older bridge versions. First import therefore needs outbound network and an unauthenticated GitHub API call. In a locked-down CI runner, an air-gapped host or a docker build without network, it fails at import rather than at request time. Its arch map draws no glibc versus musl distinction either, so there's no separate Alpine path.

hrequests then does something no other client here does: at import it asks Go for a free port, starts an in-process HTTP server, and talks to it over geventhttpclient.

Performance: the one primary benchmark

curl_cffi's repository publishes benchmark CSVs: 1,000 sequential GETs per client against a local server at three body sizes, then the same 1,000 requests across 10 threads, on a 6-core Intel Core i5 at 3.3 GHz with 32 GB RAM running macOS, per the repo's hardware.txt. Figures are total seconds, so lower is better. Below are the 1 KB and 200 KB columns from both CSVs, every client, including the cells a cherry-picked quote tends to drop; the 20 KB column is quoted in the text.

Client1 KB, 1 worker200 KB, 1 worker1 KB, 10 threads200 KB, 10 threads
pycurl0.45021.06800.12931.0445
curl_cffi (raw)0.46551.31240.14101.0463
curl_cffi (sync)0.65631.55080.35281.3735
curl_cffi (async)not runnot run0.30950.8381
tls_client0.755120.26900.262215.2174
httpx (sync)1.01491.14930.71411.3627
requests1.68571.83771.04321.4353
aiohttpnot runnot run0.29240.4401

The async clients run only in the 10-worker CSV, hence the two "not run" columns.

The shape is the story. tls_client is competitive at 1 KB and the fastest non-raw client under threading at that size. Then the curve diverges: the CSVs' middle 20 KB row already puts it at 2.0465 single-worker against curl_cffi's 0.6723, and by 200 KB it has grown about 27x from its own 1 KB figure while curl_cffi's grows about 2.4x.

The source explains why. Python tls-client marshals one JSON document across the cgo boundary per request, reads it back with a single ctypes.string_at, decodes it as UTF-8, parses it, and sets response._content = res["body"].encode(). No streaming, no chunked read. It also never sets the isByteResponse flag the Go engine supports, though it does set isByteRequest for binary request bodies, so binary responses aren't safely round-tripped. hrequests sidesteps that cost by reading over a local HTTP socket.

Six caveats, all of which matter:

  • Both CSVs were last committed on 2023-07-06, so the tls_client figures describe the 0.1.x era, not 1.0.1.
  • benchmark.py targets http://localhost:8000/, which is plain HTTP. No TLS handshake is measured at all, so this benchmark says nothing about the cost of impersonation itself.
  • The benchmark is published by curl_cffi's own maintainers.
  • It runs on loopback, which the repo's notes say doesn't represent real internet conditions.
  • hrequests is absent, and no primary benchmark anywhere includes it. Don't interpolate from tls_client just because they share an engine; the transport boundaries differ.
  • The CSVs report total duration, not requests per second, with no percentiles.

On a real scrape through a residential exit, network latency dominates all of this. The 200 KB column still matters if you pull HTML-heavy pages.

Concurrency: asyncio, gevent, or nothing

curl_cffi gives you a genuine AsyncSession alongside the sync session and requests-style module functions, plus WebSockets, native retry, HTTP/2 and HTTP/3 (http_version="v3", or "v3only" to require it). Its README matrix claims it is the only client among requests, aiohttp, httpx, pycurl and itself with fingerprint support, and the only one with both HTTP/3 and WebSocket.

import asyncio
from curl_cffi.requests import AsyncSession

API = "https://scrape.sparkproxy.io/api/v1"
HDRS = {"X-API-Key": "YOUR_API_KEY"}

async def main(targets):
    async with AsyncSession(impersonate="chrome") as s:
        tasks = [s.get(API, headers=HDRS,
                       params={"url": u, "render_js": "false",
                               "country_code": "DE", "proxy_type": "premium"})
                 for u in targets]
        for r in await asyncio.gather(*tasks):
            print(r.status_code, len(r.content))

asyncio.run(main(["https://www.sparkproxy.io/", "https://www.sparkproxy.io/proxies"]))

Python tls-client has no async surface. Its __init__.py exports exactly one name, Session. No AsyncSession, no gevent integration, no streaming API. You get threads, and you pay the per-response marshalling cost on every one.

hrequests uses gevent rather than asyncio. async_get and friends build unsent request objects that hrequests.map, imap or imap_enum evaluate through a gevent.pool.Pool.

import hrequests

API = "https://scrape.sparkproxy.io/api/v1"
HDRS = {"X-API-Key": "YOUR_API_KEY"}

reqs = [hrequests.async_get(API, headers=HDRS,
                            params={"url": u, "render_js": "false"})
        for u in ["https://www.sparkproxy.io/", "https://www.sparkproxy.io/proxies"]]

for resp in hrequests.map(reqs, size=8):
    print(resp.status_code, resp.url)

A nohup=True mode also returns a lazy object that only blocks when you touch an attribute. The README notes that it creates a new thread per request and tells you to use the concurrency helpers instead at larger scale. If your codebase is already asyncio, gevent monkey-patching is an integration cost, not a style preference.

One curl_cffi-only feature matters on repeat crawls: the sync Session takes cache=timedelta(minutes=10) or a FileCacheBackend, keyed on method, URL and body. AsyncSession rejects the option, and neither of the other two has an equivalent.

What each one does before you configure it

Behaviourcurl_cffitls-clienthrequests
`allow_redirects`follows, requests-compatible`False``True`
Default User-Agentfrom the impersonate target`tls-client/1.0.1`from the selected profile
Default browser profilealias `chrome` to `chrome150``chrome_120`firefox 132, the newest it carries
Extension order permutedper targetoffon
Binary response bodiesnative bytesre-encoded from a JSON stringover local HTTP
Proxy argument`proxy=`, `proxies=` dict, env varsbare URL stringper-request string
SOCKSdocumented as `socks://`inherited, no scheme list`socks5://`, validated in code

hrequests has two documentation traps. Its README says the Session browser argument defaults to chrome; session.py declares browser: Literal['firefox','chrome'] = 'firefox'. The same README says the TLS version is randomised by default; the code falls through to version = BROWSER_MAP[browser].versions[-1], commented "default to the latest tls", which is firefox 132. What it does randomise is the OS string the header generator works from, picked from win, mac or lin. The code wins both times. If you want Chrome, say so:

import hrequests

session = hrequests.Session(browser="chrome", version=131)
r = session.get("https://scrape.sparkproxy.io/api/v1",
                headers={"X-API-Key": "YOUR_API_KEY"},
                params={"url": "https://www.sparkproxy.io/", "render_js": "false"})
print(r.status_code)

Ask hrequests.Session for a version it doesn't carry and it raises a ValueError printing the whole supported tuple, for Chrome (103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 117, 120, 124, 131). A flooring helper, tls_version, walks down to the nearest profile, but Session validates membership before calling it, so through this entry point you get the error, not the floor.

On proxies, curl_cffi is the most explicit: a single proxy= parameter, a requests-style proxies= dict for compatibility, the http_proxy, https_proxy, ws_proxy and wss_proxy environment variables, and SOCKS documented as socks://. hrequests takes a per-request string and checks it against a regex built from {'http', 'https', 'socks5'}, raising ProxyFormatException on anything else, so its accepted set is readable in code if not in the README. Python tls-client passes the string through unvalidated and publishes no scheme list at all, leaving the Go engine's HTTP and SOCKS5 support as the only guide.

primp, the fourth option

primp belongs here for one reason: it is the only one of the four with current Edge profiles.

It is Rust, and the common description of it is wrong. Its workspace depends on a forked rustls (primp-rustls) and a forked h2 (primp-h2), not on BoringSSL and not on rquest or wreq. HTTP/3 is an optional cargo feature on h3 0.0.8 and quinn 0.11. Release 2.0.1 landed 2026-09-13; Python 3.10 and up, Rust 1.89 minimum.

Its profiles run chrome_144 to chrome_153, edge_144 to edge_153, firefox_140 plus firefox_146 to firefox_151, opera_126 to opera_135, and four Safari entries only (safari_18.5, safari_26, safari_26.3, safari_26.4), with a separate OS axis taking android, ios, linux, macos, windows or random. The catch mirrors tls-client's, inverted: nothing older than chrome_144 exists.

import primp

client = primp.Client(impersonate="chrome_146", impersonate_os="windows")
r = client.get("https://scrape.sparkproxy.io/api/v1",
               headers={"X-API-Key": "YOUR_API_KEY"},
               params={"url": "https://www.sparkproxy.io/", "render_js": "false"})

primp ships criterion benches in-repo but publishes no results, so there is no primp performance figure to quote. Its 9,001,909 downloads in the 30 days to 2026-10-09 are driven largely by packages that depend on it, so don't read that number as adoption by scrapers.

When a TLS client is enough, and when to escalate

Impersonation libraries solve one problem: making your connection look like a browser's connection. They run no JavaScript, hold no DOM, and can't produce a token that a challenge script computes in the page.

Signal on the targetTLS client is enoughEscalate to a browser
JSON or XHR endpoint, no challengeyesno
Static HTML, content present in the first responseyesno
Cookie set by a plain `Set-Cookie`yesno
Content assembled client-side after loadnoyes
Interstitial challenge page with a JS computationnoyes
Canvas, WebGL or font enumeration in the payloadnoyes
Vendor token minted by an obfuscated scriptnoyes
CAPTCHA or interactive widgetnoyes, plus a solver

The test costs two requests: fetch with an impersonating client, then fetch the same URL rendered. If the rendered body carries data the raw body does not, the content is client-side and no handshake work recovers it. If both are a challenge page, you have a token requirement, and Bypass Cloudflare Web Scraping and Bypass Akamai Bot Manager cover what each vendor checks. When the answer is a real browser, the stealth-layer comparison is in Undetected-ChromeDriver vs Patchright vs Camoufox, a different layer with a different set of trade-offs.

from curl_cffi import requests

API = "https://scrape.sparkproxy.io/api/v1"
HDRS = {"X-API-Key": "YOUR_API_KEY"}
TARGET = "https://www.sparkproxy.io/proxies"

raw = requests.get(API, headers=HDRS, impersonate="chrome",
                   params={"url": TARGET, "render_js": "false"})
rendered = requests.get(API, headers=HDRS, impersonate="chrome",
                        params={"url": TARGET, "render_js": "true",
                                "stealth": "true", "premium_proxy": "true",
                                "country_code": "US"})

print("raw", raw.status_code, len(raw.text))
print("rendered", rendered.status_code, len(rendered.text))

There is a second gap no library closes, and it fails more scrapes than fingerprinting does. A perfect Chrome handshake from a datacenter IP that has already sent ten thousand requests this hour is still a datacenter IP that has already sent ten thousand requests this hour. Handshake mimicry answers "what client is this"; the exit IP answers "who is this", and reputation scoring usually runs first. The country_code, proxy_type and premium_proxy parameters above exist for that half of the problem, and the full list is in the Scraping API docs.

Which one to use

Default to curl_cffi. It is the only one of the three under active development, last committed 2026-10-08, with the freshest profiles, real musl and ARM wheels, true asyncio, HTTP/3, WebSockets, on-disk caching, and a curl-cffi update path that refreshes fingerprints without a package bump. At 32.5 million downloads in 30 days it also has the largest pool of people hitting the same bugs you will. The tutorial is Web Scraping With curl_cffi and TLS Impersonation.

Choose hrequests when you want the uTLS handshake with batteries attached: browserforge header generation, selectolax parsing, a near 1:1 requests.Response, and optional Camoufox or Patchright extras. Accept two conditions: no commits since 2024-12-01, and a first import that downloads a binary from GitHub. Pre-warm that binary in your Docker image.

Choose Python tls-client only for a profile the others lack. The okhttp4 Android targets and the 14 mobile app profiles are unique among these three and a real reason to use it for mobile API work. For browser impersonation it is the weakest option here: chrome_120 at newest, permutation off, a self-identifying User-Agent, redirects off, no async, no streaming, and a 200 KB path an order of magnitude slower in the only benchmark that measures it.

Add primp if you need Edge, a current Opera, or the OS axis.

Pick none of them if the target never checks your handshake. Every library here trades install weight, a native blob and a frozen profile table for one capability you may not need, and the plain-client decision is a different question, answered in Requests vs httpx.

Two habits apply whichever you pick. Pin the library version when a specific profile matters, because profile tables change between releases and have shipped wrong fingerprints before. And verify what you emit instead of trusting the parameter name, because the gap between what a wrapper accepts and what the engine beneath it sends is the whole subject of this comparison.

Frequently asked questions

FAQ

For browser impersonation in 2026, yes, on most checkable axes. curl_cffi reaches chrome150 against tls-client's chrome_120, was last committed 2026-10-08 against 2024-02-02, ships real musl and ARM wheels, and offers asyncio and HTTP/3 that tls-client's Python binding doesn't expose. tls-client still wins on okhttp4 Android and mobile app profiles, which curl_cffi doesn't carry.

Yes. hrequests' bridge/go.mod requires bogdanfinn/tls-client v1.7.9 and bogdanfinn/fhttp v0.5.29, and Python tls-client binds the same Go project. They produce handshakes from one codebase at two pinned versions, so choosing between them is about ergonomics, profile freshness and defaults rather than TLS quality.

It covers the material JA4 describes but will not accept a JA4 string as input. Its docs state that it supports the entire packet, that JA4 is only part of it, and that JA4 cannot be an input because it is a hash and not parsable. Pass the components through ja3=, akamai= and extra_fp instead.

Among these three, curl_cffi 0.16.3 at chrome150, with the floating alias chrome tracking the newest target automatically. hrequests 0.9.2 reaches chrome_131, Python tls-client 1.0.1 stops at chrome_120. primp 2.0.1 goes to chrome_153 but carries nothing older than chrome_144.

Not reliably. curl_cffi's own impersonation FAQ answers "Can I change JavaScript fingerprints with this library?" with "No, you can not", because it is a Python binding to a C library with no browser or JavaScript runtime under the hood, and the same reasoning covers the other two. Matching the handshake removes one detection signal; a JavaScript challenge or a vendor token still needs a real browser. Treat the identical "TLS fingerprinting alone isn't enough" line in all three READMEs as what it is, one vendor's sponsored block, not three independent verdicts.

Yes. curl_cffi 0.16.3 publishes genuine musllinux_1_2 wheels for x86_64 and aarch64, so pip installs a prebuilt binary on Alpine with no build step. Python tls-client's loader takes its -x86.so branch on any machine whose platform.machine() contains "x86", which includes x86_64, so the binary its own README designates for Alpine is never selected automatically there.

Special Discount ยท 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

The SparkProxy Technical Team builds and operates SparkProxy's datacenter, ISP and residential proxy networks and the SparkProxy Scraping API. We run impersonating HTTP clients and headless browsers against protected targets daily at production volume, and the comparisons we publish are read from source and primary documentation rather than from other articles. Every version number, profile list, release date and benchmark figure above was verified on 2026-10-09 against PyPI, the GitHub API, the published CSVs and each project's own repository, and where a claim could not be verified from a primary source we say so in the sentence that makes it. Corrections: support@sparkproxy.io.

Keep reading

Related articles