Every browser-based proxy runs into the same wall on day one: a web page cannot open a network connection. Not a real one. There is no connect() in JavaScript, no socket API, no way for code in a tab to reach out to port 443 on a machine of its choosing. The browser sandbox exists precisely to prevent that.
Which leaves a narrow set of doors. fetch() speaks HTTP and only HTTP, and CORS decides where it may knock. WebSocket gives you one persistent two-way byte pipe — but only to a server that already agreed to speak WebSocket with you. That is the entire toolkit.
Wisp is what happens when you take the second door seriously and build a network stack behind it.
What is the Wisp protocol?
Wisp is a lightweight multiplexing protocol that carries many TCP and UDP connections over a single WebSocket, using a 5-byte header per message. A Wisp client running in a browser tab asks a Wisp server to open sockets on its behalf, then shuttles raw bytes back and forth over the one WebSocket connection they share.
The specification was written by @ading2210 and is published in Mercury Workshop’s wisp-protocol repository under the Creative Commons Attribution 4.0 licence. There are two major versions: v1 (currently 1.2), deliberately trivial to implement, and v2 (currently 2.1, on the repository’s v2 branch when we checked in October 2026), which adds a negotiated handshake, authentication, and protocol extensions. The repository lists server and client implementations in JavaScript, Python, Rust, Go, and C++. UDP support is optional for both sides.
Wisp is not the same thing as Bare, an older HTTP-based relay used by early browser-side proxies. Bare relays HTTP requests; Wisp relays raw sockets.
The useful mental model is not “a proxy protocol”. It is a fake network card for a browser tab.
BROWSER TAB PROXY SERVER THE INTERNET
[ wisp client ] ══ 1 WebSocket, port 443 ══ [ wisp server ]
stream 1 ─── TCP ───> example.com:443
stream 2 ─── TCP ───> reddit.com:443
stream 3 ─── UDP ───> 1.1.1.1:53
Multiplexing: many connections, one pipe
Multiplexing means many independent conversations sharing one physical connection. In Wisp each conversation is called a stream, and every stream corresponds to exactly one TCP or UDP socket on the server side.
The client picks a random 32-bit stream ID for each new stream and stamps it on every message belonging to that stream. The server keeps a table mapping IDs to real sockets. That is the whole trick — and it is what makes the protocol practical rather than merely possible.
Consider the alternative, which is what the older wsproxy scheme did: one WebSocket per destination, with the target host and port in the URL path (the Wisp spec asks for a trailing slash on Wisp endpoint URLs precisely so the two are not confused). A modern page pulls from twenty or thirty hosts. That becomes thirty WebSocket handshakes, thirty TLS negotiations, thirty connections for a firewall to notice and a browser connection limit to throttle.
With Wisp it is one connection, opened once, reused for everything. Stream setup costs a single message.
Inside a Wisp packet
Every Wisp message, regardless of type, starts with the same five bytes.
byte: 0 1 2 3 4 5 ...
┌────────┬───────────────────┬──────────────────┐
│ type │ stream ID │ payload │
│ uint8 │ uint32 little- │ bytes │
│ │ endian │ │
└────────┴───────────────────┴──────────────────┘
uint8 and uint32 mean an unsigned integer one byte and four bytes wide. Little-endian means the least significant byte comes first, so port 443 travels as BB 01, not 01 BB — get that backwards and nothing works, which is the traditional first bug in every fresh implementation.
Five bytes of framing. For comparison, a single HTTP request header block is typically several hundred bytes, so the framing overhead is small next to the data it carries.
One reservation matters: stream ID 0 is not a stream. It is the control channel, used for the handshake and connection-wide messages.
The five packet types
| Type | Name | Direction | Purpose |
|---|---|---|---|
0x01 |
CONNECT | client → server | Open a socket to a host and port |
0x02 |
DATA | both | Raw socket bytes |
0x03 |
CONTINUE | server → client | Flow control credit |
0x04 |
CLOSE | both | Tear down a stream, with a reason code |
0x05 |
INFO | both | Version and supported extensions |
CONNECT carries one byte for stream type (0x01 TCP, 0x02 UDP), two bytes of port, and the hostname as a UTF-8 string. Note where the hostname is resolved: on the server. The browser never looks up the destination, so DNS-level filtering on the local network sees only the lookup for the Wisp server itself.
DATA is the payload path, and it is deliberately boring — the packet body is the socket bytes, nothing wrapped, nothing encoded. The server keeps a separate first-in-first-out queue per TCP stream so ordering survives.
CONTINUE is the interesting one, because it solves a problem that sinks naive implementations. A browser can generate data far faster than a slow origin server can absorb it. Without a brake, the proxy server’s memory fills with buffered bytes and the process dies. Wisp uses a credit system:
server → CONTINUE(128) client credit = 128
client → DATA credit 127
client → DATA credit 126
... 126 more ...
credit = 0 client MUST stop sending
server → CONTINUE(128) credit refilled, client resumes
The count is in packets, not bytes. A well-behaved server tops the credit up early, before the client ever stalls. UDP streams get no credit accounting at all — datagram loss is acceptable by definition.
CLOSE carries a one-byte reason. Closing happens either way; the code is there so failures are diagnosable rather than mysterious. Some are ordinary (0x02 voluntary, 0x03 network error, 0x44 connection refused), and two are worth knowing as a user of any Wisp service: 0x48 means the proxy is intentionally blocking that destination, and 0x49 means you are being rate-limited. A client that surfaces those codes can tell you why something failed instead of showing a generic spinner.
INFO announces protocol version and a list of supported extensions — optional features such as UDP support, password authentication, Ed25519 key authentication, a server welcome message, and stream-open confirmation. Only extensions present in both sides’ INFO packets may be used.
The handshake, and one genuinely elegant trick
CLIENT SERVER
│ GET /wisp/ Upgrade: websocket
│ Sec-WebSocket-Protocol: <any value> ──────>
│ <────── 101 Switching Protocols
│ <────── INFO (stream 0)
│ INFO + credentials (stream 0) ──────>
│ <────── CONTINUE (stream 0) = accepted
│ or CLOSE = rejected
│ CONNECT / DATA on real stream IDs …
Two details do a lot of work here.
First, version selection rides on the mere presence of the Sec-WebSocket-Protocol header. Its value is ignored. Present means the client speaks v2; absent means fall back to v1. No version field to disagree about.
Second, and this is the part worth stealing: if the client’s first packet from the server is CONTINUE rather than INFO, the server is a v1 server, and the client silently drops to v1. Backward compatibility with zero extra round trips and zero negotiation. Protocols rarely get to age this gracefully.
Why Wisp matters: it moves TLS into the browser
Everything above is competent engineering. This next part is why Wisp matters for browser proxies.
A conventional web proxy fetches pages on your behalf. Your browser asks the proxy, the proxy’s server code performs the TLS handshake with the destination, reads the HTML, rewrites the links, and hands the result back. We described that flow in detail in how a web proxy works.
The problem is who performs that handshake.
CONVENTIONAL PROXY
browser ──HTTP──> proxy server ──TLS handshake by the server──> origin
▲ server decrypts, rewrites, re-encrypts
WISP TUNNEL
browser ──WebSocket──> proxy server ──raw TCP bytes──> origin
▲ TLS handshake happens HERE, ▲ server relays bytes it
inside the browser cannot read
Every TLS client leaves a signature in its opening message — the cipher list, the order of extensions, the curves it offers. Hash that and you get a JA3 or JA4 fingerprint, and it identifies the software connecting with uncomfortable precision. Real Chrome has one. Node.js has a very different one. Anti-bot services can compare them.
So a server-side proxy can be recognised before it says a word, and that is one of the reasons proxies meet challenge pages on protected sites.
Over a Wisp tunnel, the TLS handshake is performed by a TLS library compiled to WebAssembly and running in the browser tab (epoxy-tls uses Rust; libcurl.js ports libcurl). The proxy server becomes a byte relay that moves encrypted bytes it has no key for. Be precise about what that buys: the handshake is no longer the proxy server’s, but it is not Chrome’s own TLS stack either. It carries the fingerprint of whichever library the tab loaded.
Where Wisp fits for AnyProxy
AnyProxy is mainly a server-side proxy: it fetches and rewrites pages on its regional servers (Singapore, Frankfurt and Virginia on the free tier) before sending them to you. A browser-side tunnel like Wisp is at most a second path for hosts that need it, not the whole product, because the trade-off above is real: a WebAssembly TLS stack is a download and a startup delay, and spending it on sites that load fine anyway would make them slower.
It is also not what gets AnyProxy past Cloudflare’s “Just a moment…” page. As our Cloudflare explainer describes, AnyProxy completes that check for the site’s real address before the page reaches your tab, and it still fails on some sites.
What Wisp does not fix
Wisp is a transport. Transports do not solve every layer above them, and a clear-eyed list matters more than a pitch.
| Problem | Does Wisp help? |
|---|---|
| DNS-level network filtering | ✓ Destination is resolved server-side |
| IP and firewall blocks on the local network | ✓ Only one connection to the proxy is visible |
| TLS fingerprint checks at the destination | Partly. The handshake comes from a library in the tab, not the proxy server, but it is not Chrome’s own fingerprint |
| Non-HTTP protocols in a browser | ✓ Raw TCP, and UDP where both sides support it, so SSH, IRC and game protocols become possible |
| Server IP reputation | ✗ The origin still sees the Wisp server’s address |
| WebRTC leaks | ✗ The browser’s own WebRTC traffic does not go through Wisp |
| JavaScript and behavioural challenges | ✗ Different layer entirely |
| Interactive challenge widgets | ✗ Not a transport problem |
| Per-IP rate limits | ✗ Unchanged |
The IP point deserves emphasis, because it is the one most often glossed over. The Wisp server opens the TCP connection, so the destination sees the server’s address as the source. A datacenter address that a site refuses is refused whatever transport carries the bytes. Wisp changes how your connection is carried; it does not change where it comes from.
Should you build on Wisp?
Reach for it when you need real sockets in a browser — a terminal client, an emulator that wants a network card, a game protocol, anything that is not HTTP. The ecosystem is a decent argument by itself. The specification’s v2 README lists, among others, v86 (an x86 emulator running in the browser), Puter (a browser-based operating system), Ultraviolet (a web proxy that uses Wisp through libcurl.js or epoxy-tls) and Wispcraft (a mod that lets the browser game Eaglercraft join real Minecraft Java Edition servers).
Skip it when plain HTTP proxying already does the job. If you are fetching and rewriting pages server-side and it works, a 5-byte multiplexing layer and a WebAssembly TLS stack are complexity you have not yet earned.
The interesting part of Wisp is not the framing. It is that a 5-byte header was enough to let TLS run in the browser while the server only relays bytes — which changes who can read the traffic, though not the address a site sees.
If you just want the page to load
None of this needs to be your problem. Open anyproxy.site and paste the site’s address. If it does not load, the help page for pages that will not load covers the usual causes, including sites that refuse datacenter traffic.
The plumbing is only interesting until it works.