WebSocket library: websockets (default) or picows¶
Integrated via picows.websockets, not via the picows core API¶
Id: acf166bd-4af3-484e-9491-738de65186eb
Type: decision
Status: active
Evidence: confirmed
Source: maintainer instruction for the feature (“compatibility mode to the websockets API”), branch feature/websocket-library-picows, PR #475
See: https://github.com/oliver-zehentleitner-aigent/picows — 5c95d51c-1ddb-4c42-b23b-11619bf96f12 — as of 2026-09-30
BinanceWebSocketApiManager(websocket_library="picows") swaps the transport
for the whole manager instance. The integration goes through
picows.websockets (picows >= 2.0.0), a drop-in replacement of the
websockets client API: same connect() signature, same recv()/send()/
close() on the connection object, same exception names. All the selection
logic lives in unicorn_binance_websocket_api/websocket_library.py;
connection.py only asks it for the connect() callable and manager.py
catches both exception families. sockets.py (the stream loop) is untouched.
Reason: the stream loop is written against the websockets API -
await recv() wrapped in asyncio.wait_for(), one coroutine per stream in
its own thread/event loop. Reusing that path means zero duplicated stream
logic and no second code path to keep in sync. Chosen as the first step
explicitly; a native integration was to be assessed, not built (next entry).
Version floor picows>=2.3.0: 2.3.0 (2026-09-13) is the release whose
connect(proxy=...) path UBWA uses for SOCKS5 in picows mode (see “picows
mode uses picows’ native proxy path” below); 2.2.0 was the first whose
InvalidStatus.response is the websockets-shaped Response with
status_code (tarasko/picows#108,
fixed 2026-09-11, verified against 2.2.0 by the scenario suite). Before that
the floor was 2.1.0 rather than 2.0.0 (where picows.websockets first
appeared) because 2.1.0 completed the compat surface (open/closed
attributes, protocol.State, WebSocketClientProtocol alias) - UBWA does
not use those today, the floor bought the complete API in case it does.
Raising to 2.2.0 kept that and removed the need to handle two response
shapes; 2.3.0 adds the proxy path.
Native picows core API (ws_connect() + WSListener) - measured, not built¶
Id: 2bf2243d-ab9c-44ec-9334-25b20a0abebb
Type: decision
Status: active
Evidence: confirmed
Source: maintainer decision 2026-09-10 after the raw measurement below; benchmark dev/test_websocket_library_benchmark.py (--raw-libs) plus an ad-hoc core-API listener run against the same replay server
Revisit when: picows changes how picows.websockets sits on the core API (e.g. a zero-copy or batched recv()), or UBWA’s own per-message overhead has been cut so far that the transport dominates again
See: stream-loop.md#per-message-work-in-the-loop-that-is-not-the-transport—profiled — 8a62fdfc-0fcb-4790-a8b9-efa1939a8967 — as of 2026-09-28
The alternative to the compat layer was a push-model integration on picows’
core API: ws_connect() with a WSListener whose on_ws_frame() does the
dispatch directly, no recv() queue, no coroutine wake-up per message.
Measured, raw (no UBWA), same replay server, median of 3:
| Scenario | core API | picows.websockets |
websockets |
|---|---|---|---|
| aggTrade 0.2 KB | 485k msgs/s, 2.02 µs | 499k msgs/s, 1.95 µs | 338k msgs/s, 2.96 µs |
| depth20 1 KB | 441k msgs/s, 2.20 µs | 451k msgs/s, 2.18 µs | 294k msgs/s, 3.42 µs |
| depth diff 9 KB | 271k msgs/s, 3.65 µs | 240k msgs/s, 4.04 µs | 149k msgs/s, 6.77 µs |
Reason: the core API brings no measurable throughput over
picows.websockets for UBWA’s pattern - one Python callback per frame costs
the same as one coroutine wake-up out of the Cython queue that
picows.websockets uses internally. picows’ headline gains are against
websockets, not against its own compat layer. Inside UBWA the transport is
~2 of ~6 µs per message, so even the 13 % seen at 9 KB would be under 5 %
end to end. An earlier assumption in this file (1-1.5 µs of wake-up cost
recoverable) was wrong and is superseded by this measurement.
Rejected alternative: building it anyway (as a third websocket_library
value) for the push model’s side benefits - frame.payload_size instead of
sys.getsizeof(str()), a watchdog task removing the stop latency on idle
streams. Rejected because it means a second connection implementation
(~300-400 lines: frame reassembly, close handling, handshake-error mapping,
own send and watchdog tasks, async-callback bridging) to maintain, and both
side benefits are reachable inside the existing pull loop.
Consequence: websocket_library stays a two-value switch
("websockets", "picows"). The performance lever, if wanted, is UBWA’s own
per-message work - see stream-loop.md.
picows stays opt-in and non-default; 24 h soak before the release¶
Id: 28dd9498-bb93-4747-90d0-1dce83520b31 Type: decision Status: active Evidence: confirmed Source: maintainer decisions 2026-09-10 (opt-in after the scenario suite of PR #479 and the upstream report tarasko/picows#108; soak requested the same day, release only after it) Revisit when: the first opt-in users have reported back in #477 - then decide about promoting picows beyond opt-in (the soak gate below is passed, #108 is fixed in picows 2.2.0 and the floor raised)
picows support ships as an optional extra (pip install unicorn-binance-websocket-api[picows]), selected explicitly per manager,
websockets remains the default. The local scenario suite and short live
runs cover the integration; what they do not cover is time: hours against
real Binance maintenance windows and the 24 h connection limit, memory over
time. That gap is closed by a 24 h soak (dev/test_soak.py: !ticker@arr
!miniTicker@arr, five channels on the top 50 USDT markets,depth@100mson 20 of them, both libraries in parallel on the same host, metrics once a minute) before the release that ships picows support.
Reason: the remaining risk sits with users who opt in knowingly, the default path is untouched, and picows’ compat layer itself is still moving (#108, fixed upstream within a day). Real usage is expected to surface the next issues; the community channel is issue #477. The soak is the minimum evidence for “runs for a day” before telling anyone to opt in.
Not covered by the soak: userData streams and the WebSocket API against real credentials (no testnet key on the soak host), macOS/Windows.
Soak result (measured 2026-09-10 22:41 → 2026-09-11 22:41 CEST, 8 cores /
12 GB VM, Python 3.13.5, picows 2.1.3, websockets 16.0): clean for both
libraries, no picows-specific finding. picows 138.8 M messages (avg 1.6 k/s,
peak 8.3 k/s, 49.9 GB), RSS 62 → 126 MB, CPU avg 9.5 %; websockets 137.9 M
messages, RSS 63 → 149 MB, CPU avg 12.8 %. Zero errors, zero stalls (max
5 s without data), zero unrepairable streams, zero ERROR/WARNING lines.
Reconnects: 2 / 80 / 2 (picows) vs 2 / 88 / 2 (websockets) on the
arr / markets / depth streams, every one back in 5-6 s with subscriptions
re-queued. 90 % of them were keepalive ping timeout on the 250-subscription
markets connection during a 14:29-18:21 CEST window in which the rate rose
to 3-8 k msgs/s; both libraries dropped in the same seconds, so the cause is
Binance/network under load against UBWA’s ping_interval=5 /
ping_timeout=10 defaults, not the library. RSS stepped up at rate peaks and
was flat for the final 4.5 h despite further reconnects - buffer high-water
mark, not a per-reconnect leak (pattern evidence, no tracemalloc). Full
tables in the soak output directory (REPORT.md).
Why the picows exception classes are caught separately¶
Id: 038dc904-f4df-488a-98fe-4933ec066873
Type: constraint
Status: active
Evidence: confirmed
Source: picows/websockets/exceptions.py (picows 2.1.3)
picows.websockets.exceptions.ConnectionClosed & co. are not subclasses of
the websockets exception classes, they only share the names. The manager’s
restart logic therefore catches tuples
(CONNECTION_CLOSED_EXCEPTIONS, …) built in websocket_library.py; the
picows classes are appended only when the package is importable.
InvalidStatus.response differed between the families (picows < 2.2.0)¶
Id: 7bed2264-1212-4c6e-a50d-857e1ca0f60d
Type: workaround
Status: superseded
Evidence: confirmed
Source: picows 2.1.3 picows/websockets/asyncio/client.py (raise InvalidStatus(exc.response) with the raw WSUpgradeResponse); found by TestWebSocketLibrary.test_handshake_429_crashes_stream; superseded by picows 2.2.0 (tarasko/picows#108 fixed 2026-09-11) and the version floor picows>=2.2.0
Superseded by: acf166bd-4af3-484e-9491-738de65186eb
See: https://github.com/oliver-zehentleitner-aigent/picows — 6ad4cf8a-2fc9-4a7f-895e-968948fb6f30 — as of 2026-09-30
Superseded: since the extra requires picows 2.2.0, both families attach a
Response with status_code and get_http_status_code() reads that
attribute only. The helper stays as the single place the manager gets the
code from (returns None instead of raising when the shape is unexpected),
the status fallback is gone. History below.
The manager decides on a rejected handshake by HTTP status (429 -> crash the
stream, anything else -> restart). websockets puts a Response with
status_code on InvalidStatus; picows.websockets puts picows’ raw
WSUpgradeResponse there, which only has status (an HTTPStatus).
Reading .status_code therefore raised AttributeError inside the
except clause and the stream thread died silently, no status update, no
restart - the exact failure mode the fail-loud rule exists to prevent.
websocket_library.get_http_status_code() reads either attribute; the
manager uses it instead of touching the response directly.
Rejected alternative: catching the picows exception separately and mapping it before the shared handler. More code for the same outcome, and the next attribute difference would need the same treatment again; one accessor that knows both shapes is the smaller surface.
Fail loud on picows without the package¶
Id: adfa99d2-63d7-4da7-9062-751e12688d27 Type: decision Status: active Evidence: confirmed
Selecting "picows" without the optional dependency raises ImportError,
an unknown value raises ValueError - no silent fallback to websockets.
Follows the suite-wide “fail loud” rule: a deployment that thinks it runs
picows but silently runs websockets is a hidden configuration bug.
Proxies are passed to both libraries natively (proxy= URL)¶
Id: 27f66d23-a882-45d4-afe5-2be05a80efda
Type: decision
Status: active
Evidence: confirmed
Source: maintainer instructions 2026-09-13 (“picows 2.3.0 … https proxy support, check and update the implementation”, then “websockets floor to 15 and we just pass through cleanly, why not”); picows 2.3.0 release notes and picows/proxy.py; websockets 15.0 changelog (proxy support, proxy_ssl for https:// proxies); scenario tests test_socks5_proxy_* / test_http_proxy_* against local SOCKS5 and HTTP CONNECT stand-ins for both libraries; live check 2026-09-13 through the SOCKS5 stand-in to wss://stream.binance.com for both libraries with verification on and off
Revisit when: unicorn-binance-rest-api accepts a generic proxy URL - then the REST side (listenKey) can follow http(s):// proxies too and the derived socks5_proxy_* hand-over can go
BinanceWebSocketApiManager(proxy="scheme://[user:pass@]host:port") is
handed to connect(proxy=...) of whichever library is selected; the legacy
socks5_proxy_* parameters are converted into a socks5:// URL
(websocket_library.build_socks5_proxy_url(), credentials percent-encoded)
and either form derives the socks5_proxy_* attributes the REST client
needs. validate_proxy_url() rejects unknown schemes and hostless URLs at
construction (fail loud), proxy_connect_kwargs() adds the TLS context for
the hop to an https:// proxy under the name each library wants
(proxy_ssl vs. proxy_ssl_context). Failures during the hop -
ProxyError/InvalidProxy of either family, python-socks errors, and any
other OSError while a proxy is configured (the client then only ever
connects to the proxy) - become ProxyConnectionError
(Socks5ProxyConnectionError stays as alias) and the stream restarts.
Without a configured proxy the kwarg is not passed, so both libraries keep
their documented default of honouring wss_proxy/https_proxy from the
environment.
Reason: both libraries now ship a tested proxy layer covering the same
schemes (websockets since 15.0, picows since 2.3.0 - the first step of this
change, on 2026-09-13, used it for picows only). Passing the URL through
removes the blocking PySocks connect from the event loop, the manual
netloc parsing, the socket hand-over that the compat layer only tolerated
(sock= + proxy=None), the PySocks dependency of this package, and it
adds HTTP/HTTPS proxies without UBWA-side code. The price is the floor
websockets>=15.0 (released 2025-02, the version that introduced proxy=),
which the maintainer accepted as “logical to stay up to date”.
Rejected alternative: keeping the PySocks path for websockets to hold
the 14.0 floor. Rejected by the maintainer: two proxy implementations for
one parameter, and no HTTP/HTTPS proxies on the default library.
Consequence: unicorn-binance-rest-api (listenKey requests) still only
understands SOCKS5, so an http(s):// proxy covers the WebSocket
connections only; UBWA logs a warning at construction in that case.
websockets sends proxy credentials without percent-decoding - refused at construction¶
Id: 56b42fed-ce14-48ab-9d66-8adb7cdd4037
Type: workaround
Status: active
Evidence: confirmed
Source: websockets/proxy.py parse_proxy() (16.0: username/password taken from urlparse as they are, no unquote), reproduced 2026-09-13 against the SOCKS5 stand-in (ProxyError: failed to connect to SOCKS proxy, proxy counted a rejected login); python-socks parse_proxy_url() does unquote(), so picows logs in with the same URL; reported upstream as python-websockets/websockets#1761
Revisit when: #1761 is fixed and released - then raise the websockets floor to that version and drop check_proxy_credentials()
A proxy password like s3cret:@/ must be percent-encoded to fit into the
URL; websockets passes the encoded string to python-socks and to the
Proxy-Authorization header literally, the proxy rejects it, and UBWA
would restart the stream forever with ProxyConnectionError. Before the
native path the legacy socks5_proxy_pass went to PySocks raw and such
passwords worked, so this would have been a silent regression for
websockets users. websocket_library.check_proxy_credentials() therefore
raises ValueError at construction when the selected library is
websockets and the URL’s user or password contain percent-escapes (both
for proxy= and for the legacy parameters, which are encoded into the
URL). picows is unaffected and covered by
test_socks5_proxy_credentials_with_reserved_characters.
Rejected alternative: decoding the credentials in UBWA and passing
them raw. Not possible through connect(proxy=...), which only takes the
URL; raw reserved characters break urlparse.
TLS through the SOCKS5 proxy was never verified (fixed)¶
Id: 5cb53a0c-11f8-43a5-9126-09003f196435
Type: incident
Status: active
Evidence: confirmed
Source: found 2026-09-13 while moving the picows proxy path (manager.py, ssl.SSLContext() with no protocol argument: verify_mode=CERT_NONE, check_hostname=False by default, checked on Python 3.13); fixed in the same change, covered by the live check through a SOCKS5 stand-in to binance.com with verification on and off
The proxy path built the SSL context for the Binance endpoint with
ssl.SSLContext() and only disabled verification when
socks5_proxy_ssl_verification=False; with the default True nothing was
enabled, so the certificate was never checked. The direct (no proxy) path
uses the libraries’ default context and verifies. Additionally the PySocks
path passed server_hostname="host:port" (the netloc), which cannot match a
certificate; it was harmless only because nothing was verified.
Reason it slipped through: ssl.SSLContext() looks like “a default
context”, is not (create_default_context() is), and the deprecation
warning for the missing protocol argument was not surfaced by the test runs.
No test exercised the proxy path at all until the SOCKS5 stand-in.
Fix: the libraries’ default context (verifying) when verification is
on - no custom context at all; SSLContext(PROTOCOL_TLS_CLIENT) with
check_hostname=False / CERT_NONE when off; TLS kwargs are only passed
for wss:// (so the proxy path can be tested against a local ws://
server). Users whose proxy setup relied on the missing verification
(MITM proxies) get a certificate error now and have to opt out explicitly.
Benchmark results¶
Id: b8a65cf6-d48b-42dd-8924-e5682ba6f947
Type: constraint
Status: active
Evidence: confirmed
Source: dev/test_websocket_library_benchmark.py, run on the branch 2026-09-10
See: stream-loop.md#per-message-work-in-the-loop-that-is-not-the-transport—profiled — 8a62fdfc-0fcb-4790-a8b9-efa1939a8967 — as of 2026-09-28
See: https://github.com/oliver-zehentleitner-aigent/picows — 0dfae98e-3584-46ed-86cc-d5207eb3f66b — as of 2026-09-30
The numbers below are measured (confirmed). The “Reading” section is the interpretation and is inferred, not separately measured - see the note there.
Setup: dev/test_websocket_library_benchmark.py, Python 3.13.5, x86_64 Linux
(4 cores), websockets 16.0, picows 2.1.3 with aiofastnet 1.1.0 present (a
picows dependency; picows uses it for create_connection automatically when
importable, so the picows numbers include it), UBWA 2.15.2.dev. A picows based
replay server in a separate process pushes pre-serialized Binance shaped
messages as fast as it can; “CPU µs/msg” is the client process’ CPU time
(time.process_time()) divided by messages, so the server’s cost is
excluded. 3 runs each, median.
Libraries driven directly (async for msg in ws, no UBWA):
| Scenario | ~msg size | msgs | websockets msgs/s | picows msgs/s | picows speedup | websockets CPU µs/msg | picows CPU µs/msg |
|---|---|---|---|---|---|---|---|
| small_aggtrade | 0.2 KB | 300,000 | 326,982 | 493,206 | 1.51x | 3.1 | 2.0 |
| medium_kline | 0.3 KB | 150,000 | 316,559 | 491,393 | 1.55x | 3.2 | 2.0 |
| large_depth20 | 1.0 KB | 60,000 | 280,948 | 465,901 | 1.66x | 3.6 | 2.2 |
| xlarge_depth_diff | 9.1 KB | 30,000 | 143,760 | 283,667 | 1.97x | 7.0 | 3.5 |
| huge_ticker_arr | 453.9 KB | 600 | 5,842 | 6,698 | 1.15x | 172.5 | 140.7 |
| multiplex_mix | 0.2 KB | 120,000 | 304,762 | 470,529 | 1.54x | 3.3 | 2.1 |
Through UBWA, output_default="raw_data" (callback receives the JSON string) - before the stream-loop optimization (see stream-loop.md):
| Scenario | ~msg size | msgs | websockets msgs/s | picows msgs/s | picows speedup | websockets CPU µs/msg | picows CPU µs/msg |
|---|---|---|---|---|---|---|---|
| small_aggtrade | 0.2 KB | 300,000 | 116,314 | 163,406 | 1.40x | 8.7 | 6.2 |
| medium_kline | 0.3 KB | 150,000 | 112,815 | 158,541 | 1.41x | 9.0 | 6.4 |
| large_depth20 | 1.0 KB | 60,000 | 96,835 | 135,253 | 1.40x | 10.5 | 7.8 |
| xlarge_depth_diff | 9.1 KB | 30,000 | 50,746 | 51,629 | 1.02x | 20.2 | 20.0 |
| huge_ticker_arr | 453.9 KB | 600 | 1,753 | 1,597 | 0.91x | 615.9 | 676.9 |
| multiplex_mix | 0.2 KB | 120,000 | 110,738 | 153,079 | 1.38x | 9.2 | 6.7 |
Through UBWA, output_default="dict" (plus orjson.loads()) - before the stream-loop optimization:
| Scenario | ~msg size | msgs | websockets msgs/s | picows msgs/s | picows speedup | websockets CPU µs/msg | picows CPU µs/msg |
|---|---|---|---|---|---|---|---|
| small_aggtrade | 0.2 KB | 300,000 | 108,738 | 146,053 | 1.34x | 9.3 | 6.9 |
| medium_kline | 0.3 KB | 150,000 | 101,532 | 135,117 | 1.33x | 10.0 | 7.5 |
| large_depth20 | 1.0 KB | 60,000 | 79,148 | 100,820 | 1.27x | 13.1 | 10.4 |
| xlarge_depth_diff | 9.1 KB | 30,000 | 22,118 | 23,302 | 1.05x | 46.1 | 43.2 |
| huge_ticker_arr | 453.9 KB | 600 | 357 | 397 | 1.11x | 2871.3 | 2617.6 |
| multiplex_mix | 0.2 KB | 120,000 | 86,168 | 113,088 | 1.31x | 11.8 | 9.0 |
Through UBWA, output_default="raw_data" - after the stream-loop optimization (same day, same machine):
| Scenario | ~msg size | msgs | websockets msgs/s | picows msgs/s | picows speedup | websockets CPU µs/msg | picows CPU µs/msg |
|---|---|---|---|---|---|---|---|
| small_aggtrade | 0.2 KB | 300,000 | 201,912 | 403,316 | 2.00x | 5.1 | 2.5 |
| medium_kline | 0.3 KB | 150,000 | 195,460 | 371,019 | 1.90x | 5.2 | 2.9 |
| large_depth20 | 1.0 KB | 60,000 | 153,187 | 259,960 | 1.70x | 6.8 | 4.1 |
| xlarge_depth_diff | 9.1 KB | 30,000 | 64,172 | 67,972 | 1.06x | 16.3 | 15.4 |
| huge_ticker_arr | 453.9 KB | 600 | 1,768 | 1,662 | 0.94x | 608.7 | 641.0 |
| multiplex_mix | 0.2 KB | 120,000 | 180,406 | 334,188 | 1.85x | 5.7 | 3.2 |
Through UBWA, output_default="dict" - after the stream-loop optimization:
| Scenario | ~msg size | msgs | websockets msgs/s | picows msgs/s | picows speedup | websockets CPU µs/msg | picows CPU µs/msg |
|---|---|---|---|---|---|---|---|
| small_aggtrade | 0.2 KB | 300,000 | 172,638 | 296,252 | 1.72x | 5.9 | 3.4 |
| medium_kline | 0.3 KB | 150,000 | 153,152 | 248,954 | 1.63x | 6.7 | 4.1 |
| large_depth20 | 1.0 KB | 60,000 | 108,547 | 152,108 | 1.40x | 9.6 | 7.0 |
| xlarge_depth_diff | 9.1 KB | 30,000 | 24,308 | 25,582 | 1.05x | 42.0 | 39.4 |
| huge_ticker_arr | 453.9 KB | 600 | 394 | 421 | 1.07x | 2572.1 | 2465.9 |
| multiplex_mix | 0.2 KB | 120,000 | 134,163 | 202,423 | 1.51x | 7.7 | 5.1 |
Live binance.com, 20 symbol multiplex + !ticker@arr/!miniTicker@arr + 10x depth, 60 s each, sequential:
| Library | msgs/s | MB/s | CPU % of one core | CPU µs/msg |
|---|---|---|---|---|
| websockets | 441 | 0.20 | 8.7 | 198.1 |
| picows | 306 | 0.16 | 6.1 | 199.2 |
Big messages re-measured 2026-09-16 (same script, 8 cores, websockets
16.0, picows 2.3.0 with aiofastnet 1.1.0, UBWA 2.16.0.dev): a size ladder of
!ticker@arr payloads, 10 paired runs per size, medians, “wins” = paired
runs in which picows had the higher throughput. Run-to-run spread 1-4 %.
Libraries driven directly (no UBWA):
| ~msg size | websockets msgs/s | picows msgs/s | picows speedup | websockets CPU µs/msg | picows CPU µs/msg | wins |
|---|---|---|---|---|---|---|
| 9.1 KB (depth diff) | 135,837 | 274,948 | 2.02x | 7.4 | 3.6 | 10/10 |
| 32 KB | 61,155 | 95,351 | 1.56x | 16.6 | 10.6 | 10/10 |
| 97 KB | 27,851 | 54,397 | 1.95x | 36.1 | 18.5 | 10/10 |
| 227 KB | 12,420 | 25,921 | 2.09x | 80.9 | 39.0 | 10/10 |
| 454 KB | 6,430 | 11,107 | 1.73x | 156.2 | 90.6 | 10/10 |
| 908 KB | 2,710 | 3,151 | 1.16x | 370.8 | 319.1 | 10/10 |
Through UBWA, output_default="raw_data":
| ~msg size | websockets msgs/s | picows msgs/s | picows speedup | websockets CPU µs/msg | picows CPU µs/msg | wins |
|---|---|---|---|---|---|---|
| 9.1 KB (depth diff) | 64,519 | 69,259 | 1.07x | 16.3 | 15.3 | 10/10 |
| 32 KB | 23,855 | 21,101 | 0.88x | 44.7 | 50.6 | 0/10 |
| 97 KB | 8,350 | 7,630 | 0.91x | 126.6 | 137.9 | 0/10 |
| 227 KB | 3,495 | 3,361 | 0.96x | 300.5 | 316.7 | 3/10 |
| 454 KB | 1,893 | 1,725 | 0.91x | 555.0 | 611.5 | 0/10 |
| 908 KB | 866 | 822 | 0.95x | 1224.3 | 1276.3 | 1/10 |
Through UBWA, output_default="dict": 0.97x-1.11x, the JSON parse dominates
and the receive-path difference is noise.
Same 454 KB scenario through UBWA (raw_data) with one client-side change
at a time, 4-5 paired runs:
| Variant | websockets CPU µs/msg | picows CPU µs/msg | wins |
|---|---|---|---|
| default | 570.8 | 656.0 | 0/4 |
client socket SO_RCVBUF = 128 KB (--rcvbuf 131072) |
609.4 | 487.8 | 4/4 (1.27x) |
max_queue=1 |
575.8 | 627.9 | 0/5 |
max_queue=16 (both libraries' default) |
567.5 | 630.2 | 0/5 |
max_queue=None (no backpressure) |
651.2 | 598.1 | 5/5 |
picows use_aiofastnet=False |
567.3 | 668.9 | 0/4 |
strace of the default variant (600 x 454 KB): websockets 1,060 recvfrom
calls of ~256 KB (the asyncio transport’s per-read cap), picows 79 calls of
~3.4 MB. The str objects both libraries hand to UBWA are identical (type,
size, ASCII flag, scan time), so UBWA’s own per-message work is not the
difference.
Reading (inferred; the SO_RCVBUF experiment above corroborates the
big-message part):
Small and medium messages (<= ~1 KB, the bulk of Binance traffic): picows ~1.4x throughput and ~30 % less CPU per message inside UBWA. Standalone the libraries are 1.5x-2x apart.
= ~32 KB through UBWA (full
depthdiffs are still below that,!ticker@arrfar above): picows is 4-12 % slower than websockets inraw_datamode, systematically, not noise. It is an artifact of the local replay, not a parsing weakness: the server is a firehose on loopback, and UBWA’s consumer costs ~0.3-0.4 ms per 454 KB message (the"error" in/"result" inscans over the whole text), i.e. it is slower than the wire. The kernel receive buffer then autotunes into the megabytes (net.ipv4.tcp_rmemmax 6 MB on the test VM), picows drains it in one multi-MBrecvper loop iteration into a read buffer that doubles on 90 % utilization and never shrinks, and the frames are copied out and decoded one by one after the data has left the cache (plus a memmove of the leftover partial frame per read,_shrink_buffer()in picows). websockets reads at most 256 KB perrecvand stays cache-friendly. CappingSO_RCVBUFto 128 KB on the client socket removes the deficit entirely (picows 1.27x-1.40x at 454 KB). Not pinned down: a bare receive loop with the same per-message work shows the same 79-read pattern but picows stays marginally ahead there, so UBWA’s threaded layout (stream loop in its own thread, monitoring threads on the same GIL) amplifies it. Irrelevant for live Binance traffic: a WAN link at a few MB/s never fills the socket buffer like that, and the 24 h soak showed picows with less CPU and RSS. Follow-up: the two full-text scans were replaced by a head-only check (stream-loop.md, “Endpoint responses are detected in the first 256 characters”), which halves UBWA’s cost per 454 KB message for both libraries; the replay artifact itself remains (consumer still slower than loopback),--rcvbuf 131072shows picows 1.55x there.Before the stream-loop optimization UBWA added a constant ~5 µs per message on top of either library (3.1 -> 8.7 µs for websockets, 2.0 -> 6.2 µs for picows). After it (
stream-loop.md) the overhead is ~0.5 µs with picows and ~2 µs with websockets, and the picows advantage inside UBWA grew from 1.4x to 1.7x-2x for messages up to 1 KB.Live at a few hundred msgs/s the numbers are identical (~198 µs CPU/msg for both) because the manager’s fixed overhead (monitoring loops, per-stream event loops, keepalive) dominates; message rate differs between the two windows only because market activity differs. Library choice only matters for high-throughput consumers or CPU-bound hosts.
Benchmark design choices¶
Id: be0cd4b7-6992-4953-98f8-cb309933e2ed
Type: decision
Status: active
Evidence: confirmed
Source: dev/test_websocket_library_benchmark.py docstring and code
Three choices a reader of the numbers should know, all deliberate:
The replay server runs in a separate process and uses the raw picows server API, so the client process’ CPU time is the client’s alone and the sender is faster than either client (a websockets-based server in the same process would cap both clients at the same rate and blur the comparison).
Messages are pre-serialized (a pool of 500 per scenario) so the server never pays JSON encoding in the hot loop.
CPU µs per message is reported next to msgs/s because in live mode the rate is set by the exchange; CPU per message is the only number that is comparable across two live windows. Library order alternates per repeat to spread scheduling drift. The live figure still contains the manager’s fixed idle cost (monitoring loops, keepalive), which is why it is ~200 µs/msg at a few hundred msgs/s versus ~6-9 µs in the replay - it is not a per-message cost of the transport.
The sender is a firehose (no pacing,
await asyncio.sleep(0)every 200 messages) and stays so by default, with--rcvbuf BYTESas an opt-in that capsSO_RCVBUFon the client socket (decision 2026-09-16, after the big-message re-measurement above). Rejected: making a capped receive buffer or a paced sender the default. A paced sender would make the wall-clock throughput column meaningless (rate set by the pacer, as in live mode), and changing the default would break comparability with every number recorded so far; the artifact is documented instead and the switch exists for anyone who wants the WAN-like picture.

