connect-bidi-web

Full bidirectional streaming for connect-go and connect-es, over WebSockets and WebTransport.

Connect's HTTP protocol can't carry full-duplex streams in the browser. These transports can, and they plug into the server and client you already have.

github.com/sudorandom/connect-bidi-web on pkg.go.dev, @sudorandom/connect-bidi-web on npm.

What it is

Transports for connect-go and connect-es that carry the Connect envelope protocol over a full-duplex connection instead of HTTP. They use connect-go v2's Transport API and the connect-es transport interface, so they go where the HTTP transport would.

WebSocket

One shared connection, any number of concurrent RPCs. Every frame carries a stream ID so responses find their caller. Runs over TCP, so it goes through the proxies, load balancers, gateways, and serverless platforms that already carry your HTTP traffic.

WebTransport

One HTTP/3 session per client, one QUIC stream per RPC. Multiple concurrent streams share a single session with no head-of-line blocking between them.

You probably can't rely on this 'just working'. WebTransport needs HTTP/3 over UDP all the way to your handler, which rules out most L7 load balancers, gateways, and serverless platforms. Use WebSocket unless you control the whole path and have tested it. See where each transport works.

Composite transport

Keeps unary RPCs on plain HTTP for caching, observability, and proxy support, and routes only streaming RPCs over the bidi transport.

In the browser: connect-es

Client transports for the browser, used by the live demo below with the same ElizaService.

npm npm install @sudorandom/connect-bidi-web
API reference Package on npm ↗
TypeScript Browser client
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import {
  createCompositeTransport,
  createConnectWebSocketTransport,
} from "@sudorandom/connect-bidi-web";
import { ElizaService } from "./gen/connectbidi/eliza/v1/eliza_pb.js";

const transport = createCompositeTransport(
  // Unary RPCs: plain Connect over HTTP.
  createConnectTransport({ baseUrl: "https://localhost:4433" }),
  // Streaming RPCs: multiplexed onto one shared WebSocket connection.
  // connectionPerStream: true dials a connection per RPC instead,
  // trading a handshake for freedom from head-of-line blocking.
  createConnectWebSocketTransport({
    baseUrl: "https://localhost:4433",
  }),
);

const client = createClient(ElizaService, transport);

for await (const res of client.converse(requests())) {
  console.log(res.sentence); // full bidi streaming!
}
TypeScript Browser client
import { createConnectWebTransportTransport } from "@sudorandom/connect-bidi-web";

// One HTTP/3 session, reused for every streaming RPC.
const session = new WebTransport("https://localhost:4433/webtransport");

const transport = createCompositeTransport(
  createConnectTransport({ baseUrl: "https://localhost:4433" }),
  createConnectWebTransportTransport({
    baseUrl: "https://localhost:4433",
    session,
  }),
);

On the backend: connect-go

Server handlers for both transports, plus a Go client transport, built on connect-go v2's Transport API.

Go go get github.com/sudorandom/connect-bidi-web
API reference on pkg.go.dev ↗
Go Server
server := connect.NewServer()
elizav1connect.RegisterElizaServiceHandler(server, elizaServer{})

// One WebSocket connection, many concurrent RPCs (demuxed by stream
// ID), over a plain net/http handler.
http.Handle("/websocket", connectwebsocket.NewHandler(server))
Go Client
transport := connectwebsocket.NewTransport("wss://api.example.com/websocket")
client := elizav1connect.NewElizaServiceClient(connect.NewClient(transport))

stream := client.Converse(ctx)
// stream.Send(...), stream.Receive(), full duplex.
Go Server
server := connect.NewServer()
elizav1connect.RegisterElizaServiceHandler(server, elizaServer{})
handler := connectwebtransport.NewHandler(server)

// WebTransport rides HTTP/3: sessions arrive over UDP + TLS.
mux := http.NewServeMux()
wtServer := &webtransport.Server{
	H3: &http3.Server{Addr: ":443", Handler: mux, TLSConfig: tlsConfig},
}
// One HTTP/3 session per client, one QUIC stream per RPC.
mux.Handle("/webtransport", handler.UpgradeHandler(wtServer))

log.Fatal(wtServer.ListenAndServe())
Go Client
dialer := &webtransport.Transport{
	TLSClientConfig: &tls.Config{NextProtos: []string{http3.NextProtoH3}},
}
_, session, err := dialer.Dial(ctx, "https://api.example.com/webtransport", nil)

transport := connectwebtransport.NewTransport(session)
client := elizav1connect.NewElizaServiceClient(connect.NewClient(transport))

Live demo

Bidirectional streaming against a live Eliza service. The dropdown controls which transport carries the streams.

A server-streaming Introduce RPC greets you by name, then one long-lived Converse RPC carries the conversation. Two RPC types, multiplexed on the same connection.

Concurrent RPC streams

Each lane below opens an independent stream.

Run it yourself

The whole demo lives in the repo: this site, the Go server, and the Cloudflare Workers variant.

Shell Local demo
git clone https://github.com/sudorandom/connect-bidi-web
cd connect-bidi-web
mise install       # dev tools: go, node, buf, just, mkcert, wrangler, ...

just demo          # Go server: https://localhost:4433
                   #   Connect HTTP + WebSocket + WebTransport
just demo-worker   # Cloudflare workerd: http://localhost:8787
                   #   Connect HTTP + WebSocket (no WebTransport on Workers)

The first run of just demo creates a locally-trusted TLS certificate with mkcert. Browsers hold WebTransport certificates to stricter rules than HTTPS, so local WebTransport needs one tweak on top:

  • In Chrome, enable chrome://flags/#webtransport-developer-mode. Without it, WebTransport ignores locally-installed roots like mkcert's.
  • In Firefox, open about:config and set network.http.http3.disable_when_third_party_roots_found to false.

Each server serves this same page, with the live demo above talking to whichever one you opened.

Where each transport works

Transport Browsers Infrastructure
WebSocket Everywhere Nearly universal Passes through proxies, load balancers, and edge platforms (Cloudflare Workers, Vercel, …)
WebTransport Chrome 97+, Edge 98+, Firefox 114+, Safari 26.4+ In every major browser since March 2026 Patchy Needs an end-to-end HTTP/3 (UDP) path; most load balancers, gateways, and edge platforms can't carry it yet

The demo above checks both ends before offering WebTransport: the browser has to expose the API, and the server has to answer over HTTP/3. If either can't, the dropdown option is disabled.

How the protocol works

One rule: stay as close to the Connect protocol as possible, and add only what a raw socket cannot provide itself.

On the wire, a streaming Connect RPC is a sequence of envelopes: a flag byte, a big-endian length, and a payload. connect-bidi-web keeps all of it, byte for byte: the same codecs, the same per-message compression, the same error codes and details, the same EndStreamResponse JSON that ends every stream. A Connect server could almost be fooled.

What it can't keep is everything Connect quietly inherits from HTTP. Once a WebSocket finishes its upgrade, HTTP leaves the room: there are no more per-request headers, no way to tell concurrent RPCs apart, no half-close, and no RST_STREAM to cancel one call without killing the rest. Each of those gaps becomes one small, explicit piece of protocol:

Headers frame (0x06)

A new envelope type opens every stream, carrying what would have been the HTTP headers as JSON metadata: the :path of the procedure, the content type, compression negotiation, deadlines.

Stream ID

Every WebSocket frame is prefixed with a 4-byte stream ID so many RPCs can share one connection. The receiver uses it to match each message to the appropriate caller. Clients assign IDs starting at 1 and never reuse them, exactly the job HTTP/2 stream IDs do for Connect over HTTP.

End-stream envelope (0x02)

Straight from Connect: the response stream ends with the standard EndStreamResponse JSON. Requests reuse it with an empty payload as an explicit half-close (“done sending, still listening”), since neither the stream nor the WebSocket has one of its own.

Reset frame (0x07)

Cancellation. Closing a shared connection would kill every RPC on it, and a client that already half-closed has no other frame left to send. So, like HTTP/2's RST_STREAM, a reset aborts exactly one stream and the server cancels that handler's context.

Wire format One WebSocket message = one frame
┌───────────┐┌──────┐┌────────────┐┌────────────────┐
│ stream ID ││ flag ││ length     ││ payload …      │
│ (4 bytes) ││ (1)  ││ (4 bytes)  ││                │
└───────────┘└──────┘└────────────┘└────────────────┘
             └──── a standard Connect envelope ─────┘
Flag Frame
0x00 data
0x01 compressed data
0x02 end-stream
0x06 headers
0x07 reset

WebTransport needs almost none of this. QUIC already gives every RPC its own independently flow-controlled stream, with native half-close and cancellation, so there the protocol is exactly Connect envelopes plus the headers frame, and nothing else. The stream ID and reset frame exist only where the transport can't do that job itself.

Sharing one ordered TCP connection has a real cost, though: head-of-line blocking. A huge message on one stream, or a stalled consumer, delays every frame queued behind it, and unlike HTTP/2 there is no per-stream flow control. For RPCs that can't tolerate that, both clients take an option (connectionPerStream in TypeScript, WithConnectionPerStream() in Go) that dials a dedicated WebSocket connection per streaming RPC instead. It's purely a client-side choice: the protocol is identical either way, and a dedicated connection is just a multiplexed connection that happens to carry one stream.