Skip to content

Rationale

muxws exists because a WebSocket gives you exactly one thing: an ordered sequence of messages. Every application that needs more than one conversation on that sequence has to build the same layer, and that layer is larger than it looks the first time you start it.

One socket, many streams

A stream in muxws is an independently addressed, independently cancellable, bidirectional exchange. Any number of them share one socket. Each carries its own payloads, its own optional headers, its own end, and its own failure.

There are two other ways to get concurrency, and both cost more than they appear to.

Why not N sockets

Opening a socket per conversation moves the problem into the transport, where you have less control over it, not more.

  • Each socket is a TCP connection and a TLS handshake. A browser also caps how many it will hold open to one host, and that cap is not yours to raise.
  • Order between sockets is undefined. Two messages that must be applied in sequence cannot be, once they are on different connections.
  • Cancelling one conversation means closing its socket. If you multiplexed even two things onto it to save connections, you have just thrown away the other one.
  • Reconnect is per socket. Backoff, jitter, and re-authentication get written N times and drift.
  • On the server, each socket is a separate authenticated session to accept, track and tear down.

With muxws, cancelling a stream sends one frame and closes one exchange. The socket, and every other stream on it, is untouched.

Why not your own envelope

The other route is one socket and a message envelope of your own: a { id, type, body } wrapper and a map of pending callbacks. This works, and then it grows.

You add a correlation id, because replies arrive out of order. Then a "this is the last one" flag, because some replies are a sequence rather than a value. Then a distinct error shape, because an error is not a reply. Then cancellation, because the user navigated away and the export is still running. Then you notice that a 4 MB message blocks every other message on the socket - a WebSocket has a single global message order, and a large message occupies it from first byte to last - so you add fragmentation. Then you discover fragmentation alone does not help, because a naive sender emits all the fragments of one payload before looking at anything else, so you add a round-robin between the fragmenting messages.

That list is muxws. Every item on it is reachable from "I just need to tell replies apart", and none of them is optional once you are far enough in.

What you get instead, already written and already tested against a shared cross-language corpus:

  • Stream ids you never allocate. The dialer takes odd ids, the acceptor even ones, so both ends can open at any moment without colliding and without asking. No API anywhere takes an id.
  • end as a flag on a payload, not a separate message. A one-payload reply is one frame.
  • Reset codes with a defined reaction. "Not accepted, definitively not processed - retry elsewhere" is a different answer from "the handler raised", and the caller can act on the difference. See Errors.
  • Per-stream cancellation that closes locally at once and tells the remote to stop working.
  • Automatic fragmentation with a round-robin writer, so a 1 MB export does not stall a 200-byte progress update on another stream. See Sizes & fragmentation.
  • A reconnect helper with jittered backoff, an idle-only heartbeat, and an opening payload replayed verbatim on every connection. See Reconnect.

Where the model comes from

muxws did not invent this. It is the stream model HTTP/2 and HTTP/3 already settled on, moved onto a WebSocket - and the reason it is worth copying is that every problem in the section above was solved there first, in public, under load, by people who had to live with the answer.

What is deliberately mimicked:

  • many independent streams multiplexed over one connection;
  • either end able to open one, so a server push is not a special case;
  • headers, then a body, then optional trailers;
  • unary request/response and streaming responses built on the same primitive;
  • per-stream cancellation that leaves every other stream alone;
  • connection-level graceful shutdown with a goaway naming the last stream it processed.

If you have written against gRPC or an HTTP/2 client, open(), end, trailers and goaway will already read the way you expect. That is on purpose.

What is not mimicked, and cannot be

This is a claim about semantics, not about transport, and the difference is worth stating plainly rather than leaving you to discover it.

A WebSocket runs over one TCP connection. Therefore:

  • No independent per-stream loss recovery. QUIC's headline feature is that a lost packet stalls only the stream it belonged to. Under TCP one lost segment stalls every muxws stream until it is retransmitted, and no framing choice above the transport can undo that.
  • No 0-RTT, no connection migration, no per-stream congestion control. Those live in QUIC, below where muxws sits.
  • One global message order. A WebSocket delivers messages in the order they were sent, across all streams, because there is exactly one send queue. This is why the frame cap is a constant rather than something you tune: it bounds how long any one stream can hold that queue.

And one thing that is deliberately not copied even though it could have been: HTTP/2 and HTTP/3 open with a SETTINGS exchange, and muxws has none at all. No limit is ever negotiated - see Connection lifecycle.

The blocking that is solved, and the blocking that is not

The first bullet above says what muxws does not fix and can read as though multiplexing bought you nothing. It bought you the half you meet daily. There are two different problems with the same name.

Application-level head-of-line blocking is self-inflicted: you put more than one conversation on one channel, and a 1 MB export occupies the send queue from its first byte to its last while a 200-byte progress update waits behind it. This is solved. Large payloads are fragmented, the frame cap is a constant so no stream can hold the queue for longer than one frame, and the writer rotates between streams with at most one prepared frame each - see Sizes & fragmentation.

Transport-level head-of-line blocking is inflicted by the network: one lost TCP segment stalls every stream until it is retransmitted. This is not solved, and cannot be above TCP. Loss is visible only to whoever owns the packets, and by the time a message reaches muxws the kernel has already either repaired the gap or is still holding everything behind it. QUIC fixes this by being the transport.

So: muxws removes the blocking you cause yourself and leaves the blocking the network causes you. Comparison to HTTP/2 and HTTP/3 is the full accounting.

The three things muxws is not

Not a router. There is one incoming-stream handler per peer, registered with on_stream, and it is invoked as (payload, stream) for every stream the remote opens. muxws does not look inside the payload to decide anything: no path matching, no method dispatch, no handler table, no per-action registration. If you want dispatch, write it in your handler, where you can see your own types.

python
# fragment
@peer.on_stream
async def handle(payload, stream):
    # Dispatch is yours. muxws never reads this dict.
    if payload["action"] == "list":
        await stream.reply({"items": []})

Not a serializer of domain objects. The codec turns a frame into a WebSocket message and back; what your payload means is not muxws's business. muxws defines no vocabulary inside payload - no kind, no reserved key, no discriminator of any sort. A payload is whatever your codec can encode. See Codecs.

Not an RPC framework. There are no service definitions, no schemas, no generated stubs, and no status codes: muxws never maps an exception to a numeric status of any kind. A handler that raises produces a reset carrying a reason and an optional structured error object, and the caller sees a RemoteError - not a 500.

It is also not an authentication mechanism. Authentication belongs at the WebSocket upgrade, before the peer exists; muxws interprets no credential anywhere, including in per-stream headers. See Transports.

Symmetry: one Peer type per language

There is one Peer class in Python and one in TypeScript, and both ends of a connection use it. The dialer's peer and the acceptor's peer differ in exactly two things: which parity of stream id they allocate, and that only a dialer can have a reconnect helper, because only a dialer can dial.

Everything else is the same object. The acceptor can call open(), notify() and request() whenever it likes, and the dialer receives those streams through its on_stream handler. Server push is not a second mechanism bolted on: it is a client request with the roles swapped, with the same correlation (a stream id) and the same cancellation story (stream.cancel() from either end).

python
# fragment
# On the acceptor, pushing to a client - the same three calls a client makes.
await peer.notify({"event": "deploy-finished"})
answer = await peer.request({"question": "are you still there?"}, timeout=5.0)  # 5.0 seconds
async for chunk in peer.open({"stream": "logs"}):
    print(chunk)
typescript
// fragment
// On the dialer, receiving those pushes - the same handler an acceptor registers.
peer.onStream(async (payload, stream) => {
  await stream.reply({ ok: true });
});

A consequence worth stating plainly: a peer may open a stream on its very first frame, in either direction, because there is nothing either side must send first. See Connection lifecycle.

See also

Peer · Stream · connect · accept · Errors

Released under the MIT License.