Skip to content

Troubleshooting

Every error is a TransportError with a code and a remedy. A failed connect() rejects with one and leaves it in the snapshot as lastError. A failed call() or stream() rejects with one. A session that closes on an error leaves it in lastError too. The codes below are in the order you are likely to meet them.

Nothing this library writes is written for your users. Every string on an error is English, addressed to a developer, and names functions and ports. Show a user your own sentence, chosen by code, and log the rest.

fieldforwhat it is
codebranchingOne of the codes below. Stable, and a typed union.
reason, on a RefusedError, and refused.reason on the snapshotbranchingThe code your own server gave refuse(), so you know every value it can take.
messagelogsThe code, what happened, and the remedy, in one string. It can name the address you dialled, without its query, so a token in the URL is never in it.
remedylogsWhat to do about it, addressed to whoever is reading the log.
causea debuggerThe platform’s own error, where one was wrapped.
import { RefusedError, type TransportError } from 'transport-io'
export function forUser(e: TransportError): string {
if (e instanceof RefusedError) {
return e.reason === 'banned' ? 'This account is suspended.' : 'Please sign in again.'
}
switch (e.code) {
case 'WT_NO_SUPPORT':
return 'This browser is not supported. Try Chrome or Firefox.'
case 'WT_PROTOCOL_VERSION_MISMATCH':
case 'WT_CONTRACT_MISMATCH':
return 'A new version is available. Reload the page.'
default:
return 'Connection lost.'
}
}
export function forLogs(e: TransportError): Record<string, unknown> {
return { code: e.code, message: e.message, cause: e.cause }
}

There is no shorter field to print instead: what message says between the code and the remedy is addressed to a developer too.

The runtime has no WebTransport. Safari, or anything that is not Chrome or Firefox. Use one of those two, or give the client the fallback: a WebSocket that carries emits, for a contract whose unreliable events declare what they accept there. With a fallback configured, this code means the runtime has no WebSocket either.

connect() rejected. The browser reports one error for a server that is not running, a UDP port that is unreachable, a wrong pinned hash and an expired certificate alike, so check in this order: the server is running and its UDP port is reachable; a pinned certificate is inside its 14 days; the hash is SHA-256 over the certificate’s DER bytes, not over cert.pem. npx transport-io dev handles all three locally. The message says whether the origin answered over HTTPS. One that listens only on UDP never does, and is healthy. With a fallback configured, the WebSocket was dialled after this and failed too.

The handshake failed and the same origin answered over HTTPS, so the server is up and only the QUIC path is failing: a firewall or VPN on this network, or a platform in front of the server with no UDP ingress. Nothing in the library routes around it. With a fallback configured the WebSocket is dialled, so this surfaces only when it failed too; without one, the network is the fix. On a network where it worked before, rule out a wrong or expired pinned hash, which fails the same way.

connectDev() found the development certificate past its validity. Restart npx transport-io dev, which mints a new one, then reload the page so it picks up the new hash.

connectDev() or listenDev() ran outside transport-io dev. The page is not on loopback; or nothing serves the manifest at /.well-known/transport-io-dev, because the server was not started through the command or the page is served by something else that does not proxy that path to it; or the server process found no certificate in its environment. Anywhere that is not local development, connectBrowser and listenHttp3 with a certificate of your own. See Certificates.

The listener’s authorize refused this peer. The error is a RefusedError and its reason is the server’s, 'refused' where it gave none; the snapshot has the same in refused. The session closed before the server sent anything, so nothing about the contract reached the page. A refusal is final: it does not dial the fallback, and a client with reconnect has stopped. Obtain a valid credential, usually a token in the WebTransport URL’s query string, then disconnect() and connect().

A listener was asked to bind a port another process holds. The message names the port and, for a TCP port, the loopback address that answered: transport-io dev checks both 127.0.0.1 and ::1, because a server bound to :: alone lets 127.0.0.1 bind beside it and then takes the browser’s localhost. The QUIC binding reports nothing for a held UDP port, so the check runs before it binds, and transport-io dev checks its WebTransport port before it prints a URL for it, with or without a server entry. Stop the other process, or pass another port. A transport-io dev that was killed with SIGKILL leaves the server it started running, and that server is the process holding the WebTransport port.

The session opened and no application bytes arrived within 5000 ms. Safari does this: it establishes a session and never sends. With a fallback configured, the WebSocket is dialled when this fires over WebTransport, so a Safari user is connected after 5 seconds and you see this error only when the WebSocket failed too. Without one, use Chrome or Firefox. On a server, it is a client that connected and sent nothing.

The two sides speak different protocol versions and the session closed. The protocol requires an exact match, so deploy both sides on the same library version. A minor release may change the wire, so pin the minor.

An event both sides share is declared differently, a different lane or a different id, and the session closed. Deploy the same contract on both ends. At defineContract it means two event names hash to the same id: set an explicit id on one of them, and do not rename events to get out of it.

The session was refused before the handshake because it cannot carry the unreliable lane as declared: a WebSocket session with an unreliable event that declares no fallback, or a WebTransport session negotiated reliable-only. Declare a fallback on every unreliable event, unreliable(schema, { fallback: 'newest' }), or connect over WebTransport.

call() or stream() on a fallback session. A WebSocket has no bidirectional streams. Check client.native first: it is null on a fallback session and the client on a WebTransport one. Emits, and unreliable events that declare a fallback, still work.

The event is not in the contract, or the method does not match its declaration: call() on an event that is not rpc, stream() on one that is not streaming. Add it, check the spelling, or use the method the declaration asks for.

A payload failed the contract’s schema on arrival, and the message names the field. Or a payload could not be serialised on the way out: a cycle, a function, a BigInt, or undefined as the whole payload. Send null rather than undefined. See Types, or a schema.

The responder’s handler threw. call() or stream() rejects with the handler’s message, and with this code unless the handler threw a TransportError, whose own code is carried instead. For a stream, the elements yielded before the throw were delivered.

The caller’s signal fired, the deadline passed, or cancel() was called. The stream was reset, so the responder was told and its ctx.signal fired. This is routine. Retry if the work is idempotent, or raise the deadline.

The session closed while the operation was pending, or emit() ran before connect() resolved. Reconnect. A reconnect is a new session and does not restore room membership. See Reconnecting.

The emit queue passed 256 frames and the session closed. The other side is not consuming as fast as this side emits. Emit less, or move the high-rate event to the unreliable lane, where the newest wins and the queue drops rather than closes. See Backpressure.

More than 256 calls open at once on one session. The excess stream is reset without being read and the session stays open. Reduce concurrency and retry.

An unreliable payload larger than the path allows. The message gives both sizes. Shorten it, or declare the event reliable, where the cap is much higher.

A reliable frame above the cap for its type. The message gives both sizes. Use a call rather than an emit, or split the payload. The caps are in the wire protocol.

A frame arrived before the handshake. Between two copies of this library it does not happen. Another implementation is sending before its handshake frame.

A framing violation: a bad frame, a zero-length payload, an unknown frame type. Between two copies of this library on the same version it does not happen, and the message names the section of the wire protocol to check the other implementation against. Locally it also means call() on an unreliable event, which has no response to await: use emit(), or declare the event on the reliable lane.

A frame declares a codec other than the two this version speaks, JSON and bytes. Another implementation is sending it. A frame under the wrong one of those two for an event’s slot, JSON where the contract says bytes() or the reverse, is WT_PROTOCOL_ERROR naming the event and the slot: the two sides have different contracts.

In the code list and never raised by this version. Rooms are server-authoritative, so there is no client-side room operation to refuse.