WebSockets
Server-side WebSockets in Bun
Bun.serve() supports server-side WebSockets, with on-the-fly compression, TLS support, and a Bun-native publish-subscribe API.
⚡️ 7x more throughput
Bun's WebSockets are fast. For a simple chatroom on Linux x64, Bun can handle 7x more messages per second than Node.js + "ws".
| Messages sent per second | Runtime | Clients |
|---|---|---|
| ~700,000 | (Bun.serve) Bun v0.2.1 (x64) | 16 |
| ~100,000 | (ws) Node v18.10.0 (x64) | 16 |
Internally Bun's WebSocket implementation is built on uWebSockets.
Start a WebSocket server#
The following server, built with Bun.serve, upgrades every incoming request to a WebSocket connection in the fetch handler. You declare the socket handlers in the websocket parameter.
Bun.serve({
fetch(req, server) {
// upgrade the request to a WebSocket
if (server.upgrade(req)) {
return; // do not return a Response
}
return new Response("Upgrade failed", { status: 500 });
},
websocket: {}, // handlers
});Bun supports these WebSocket event handlers:
Bun.serve({
fetch(req, server) {}, // upgrade logic
websocket: {
message(ws, message) {}, // a message is received
open(ws) {}, // a socket is opened
close(ws, code, message) {}, // a socket is closed
drain(ws) {}, // the socket is ready to receive more data
ping(ws, data) {}, // a ping is received
pong(ws, data) {}, // a pong is received
error(error, ws) {}, // one of the handlers above threw
},
});Every handler is optional.
An API designed for speed
In Bun, you declare handlers once per server, instead of per socket.
You pass a single WebSocketHandler object to Bun.serve() with methods for open, message, close, drain, ping, pong, and error. This design differs from the client-side WebSocket class, which extends EventTarget (onmessage, onopen, onclose).
Clients tend to have few socket connections open, so an event-based API makes sense there.
But servers tend to have many socket connections open, which means:
- Time spent adding/removing event listeners for each connection adds up
- Extra memory spent on storing references to callback functions for each connection
- Usually, people create new functions for each connection, which also means more memory
Reusing one handler object across every connection avoids both costs.
The first argument to each handler is the ServerWebSocket instance handling the event. The ServerWebSocket class is a fast, Bun-native implementation of WebSocket with some additional features.
Bun.serve({
fetch(req, server) {}, // upgrade logic
websocket: {
message(ws, message) {
ws.send(message); // echo back the message
},
},
});Sending messages#
Each ServerWebSocket instance has a .send() method for sending messages to the client. It supports a range of input types.
Bun.serve({
fetch(req, server) {}, // upgrade logic
websocket: {
message(ws, message) {
ws.send("Hello world"); // string
ws.send(new ArrayBuffer(8)); // ArrayBuffer
ws.send(new Uint8Array([1, 2, 3])); // TypedArray | DataView
ws.send(new Blob(["binary"])); // Blob
},
},
});Headers#
Once the upgrade succeeds, Bun sends a 101 Switching Protocols response per the spec. To attach additional headers to this Response, pass them to server.upgrade().
Bun.serve({
async fetch(req, server) {
const sessionId = await generateSessionId();
server.upgrade(req, {
headers: {
"Set-Cookie": `SessionId=${sessionId}`,
},
});
},
websocket: {}, // handlers
});Contextual data#
Attach contextual data to a new WebSocket in the .upgrade() call. It is available on the ws.data property inside the WebSocket handlers.
To strongly type ws.data, add a data property to the websocket handler object. This types ws.data across all lifecycle hooks.
type WebSocketData = {
createdAt: number;
channelId: string;
authToken: string;
};
const ALLOWED_ORIGINS = new Set(["https://my-app.example"]);
Bun.serve({
fetch(req, server) {
// browsers always send Origin, so reject requests from other sites
const origin = req.headers.get("origin");
if (origin && !ALLOWED_ORIGINS.has(origin)) {
return new Response("Forbidden", { status: 403 });
}
const cookies = new Bun.CookieMap(req.headers.get("cookie") ?? "");
server.upgrade(req, {
// this object must conform to WebSocketData
data: {
createdAt: Date.now(),
channelId: new URL(req.url).searchParams.get("channelId") ?? "general",
authToken: cookies.get("X-Token") ?? "",
},
});
return undefined;
},
websocket: {
// TypeScript: specify the type of ws.data like this
data: {} as WebSocketData,
// handler called when a message is received
async message(ws, message) {
// ws.data is now properly typed as WebSocketData
const user = getUserFromToken(ws.data.authToken);
await saveMessageToDatabase({
channel: ws.data.channelId,
message: String(message),
userId: user.id,
});
},
},
});Previously, you could specify the type of ws.data with a type parameter on Bun.serve, like Bun.serve<MyData>({...}). Bun removed this pattern in favor of the data property because of a limitation in TypeScript.
To connect to this server from the browser, create a new WebSocket.
const socket = new WebSocket("ws://localhost:3000/chat");
socket.addEventListener("message", event => {
console.log(event.data);
});Identifying users with cookies
The browser sends cookies set on the page along with the WebSocket upgrade request. They are available on req.headers in the fetch handler. Parse them to identify the connecting user and set data accordingly.
The same-origin policy doesn't apply to WebSocket handshakes. Depending on the cookie's SameSite attribute, a page on another site can open a socket that sends your user's cookies (cross-site WebSocket hijacking). Check the Origin header against an allowlist before calling server.upgrade().
Pub/Sub#
Bun's ServerWebSocket includes a native publish-subscribe API for topic-based broadcasting. You specify a topic with a string identifier. An individual socket can .subscribe() to a topic and .publish() messages to all other subscribers to that topic (excluding itself). This topic-based broadcast API is similar to MQTT and Redis Pub/Sub.
const server = Bun.serve({
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/chat") {
console.log(`upgrade!`);
const username = getUsernameFromReq(req);
const success = server.upgrade(req, { data: { username } });
return success ? undefined : new Response("WebSocket upgrade error", { status: 400 });
}
return new Response("Hello world");
},
websocket: {
// TypeScript: specify the type of ws.data like this
data: {} as { username: string },
open(ws) {
const msg = `${ws.data.username} has entered the chat`;
ws.subscribe("the-group-chat");
server.publish("the-group-chat", msg);
},
message(ws, message) {
// this is a group chat
// so the server re-broadcasts incoming message to everyone
server.publish("the-group-chat", `${ws.data.username}: ${message}`);
// inspect current subscriptions
console.log(ws.subscriptions); // ["the-group-chat"]
},
close(ws) {
// Bun unsubscribes closed sockets from all topics automatically
const msg = `${ws.data.username} has left the chat`;
server.publish("the-group-chat", msg);
},
},
});
console.log(`Listening on ${server.hostname}:${server.port}`);Calling .publish(topic, data) sends the message to all subscribers of a topic except the socket that called .publish(). To send a message to all subscribers of a topic, use the .publish() method on the Server instance.
const server = Bun.serve({
fetch(req, server) {}, // upgrade logic
websocket: {
// ...
},
});
// listen for some external event
server.publish("the-group-chat", "Hello world");Compression#
Enable per-message compression with the perMessageDeflate parameter.
Bun.serve({
fetch(req, server) {}, // upgrade logic
websocket: {
perMessageDeflate: true,
},
});Bun then negotiates compression with clients that support it. You choose per message whether to compress. ws.send() and ws.publish() don't compress unless you pass true as the last argument.
ws.send("Hello world", true);
ws.publish("the-group-chat", "Hello world", true);server.publish() is the exception. It compresses unless you pass false. Compression makes short messages larger, so pass false for those.
server.publish("the-group-chat", "Hello world"); // compressed
server.publish("the-group-chat", "Hello world", false); // uncompressedClients that didn't negotiate compression always receive uncompressed messages.
To configure each direction separately, pass an object.
Bun.serve({
fetch(req, server) {}, // upgrade logic
websocket: {
perMessageDeflate: {
compress: "16KB", // messages the server sends
decompress: "shared", // messages the server receives
},
},
});| Value | compress | decompress |
|---|---|---|
"shared", true | One compressor for all sockets, reset after each message | One decompressor for all sockets |
"3KB" ... "256KB" | Per-socket compressor that keeps context across messages | Per-socket decompressor with that window size (max 32 KB) |
"dedicated" | Same as "256KB" | Same as "32KB" |
"disable", false | Never compress, even if send() passes true | No per-socket decompressor. Uses "shared" if compress is enabled |
Per-socket compressors compress better, but each connection uses that much memory ("16KB" costs 16 KB per connection). Disabling both directions disables the extension.
Backpressure#
The .send(message) method of ServerWebSocket returns a number indicating the result of the operation.
-1— The message was enqueued but there is backpressure0— The message was dropped because the connection is closed or already has more thanbackpressureLimitbytes buffered1+— The number of bytes sent
Sending an empty payload (ws.send(""), ws.ping(), ws.pong()) succeeds but also returns 0.
After -1, Bun calls the drain handler once the buffered data has been written.
Bun checks backpressureLimit before queueing a message, so a connection can buffer up to the limit plus one message. .publish() buffers unsent bytes separately for each subscriber, so keep messages small when a topic has many subscribers.
To slow down a client that sends faster than you can consume, call ws.pause(). Bun stops reading from the socket, so the client sees TCP backpressure. ws.resume() starts reading again, and ws.isPaused reports the current state. The idle timeout is suspended while a socket is paused. Messages already read from the socket are still delivered.
Bun.serve({
fetch(req, server) {}, // upgrade logic
websocket: {
message(ws, message) {
if (!file.write(message)) {
ws.pause();
file.once("drain", () => ws.resume());
}
},
},
});Closing connections#
ws.close(code, reason) sends a Close frame and then closes the connection. The close handler receives the same code and reason.
ws.close(); // 1000
ws.close(1011, "Something went wrong");
ws.terminate(); // no Close frame- Clients only accept
1000-1003,1007-1014and3000-4999. A browser that receives any other code reports1006. 0,1005and1006send a Close frame with no code, so you can forward the code from aclosehandler to another socket.- The reason is truncated to 123 bytes of UTF-8.
When the client violates the protocol, Bun sends a Close frame with 1002 (protocol error), 1007 (invalid UTF-8 or compressed data) or 1009 (message too big). The close handler receives 1006, with a description of the problem as the reason.
server.stop() doesn't close open WebSockets. server.stop(true) closes each one with 1001.
Errors#
When a handler throws, Bun calls the error handler with the error and the socket. Unlike the other handlers, the socket is the second argument. Without an error handler, the error is an uncaught exception. If the open handler throws, Bun closes the socket.
Bun.serve({
fetch(req, server) {}, // upgrade logic
websocket: {
message(ws, message) {
throw new Error("oops");
},
error(error, ws) {
console.error(ws.remoteAddress, error);
ws.close(1011);
},
},
});Bun doesn't call error when an async handler rejects. That's an unhandled rejection.
Timeouts and limits#
By default, Bun closes a WebSocket connection that has been idle for 120 seconds. Configure this with the idleTimeout parameter, an integer number of seconds from 0 to 960. Values from 1 to 7 round up to 8.
Bun.serve({
fetch(req, server) {}, // upgrade logic
websocket: {
idleTimeout: 60, // 60 seconds
},
});Bun pings the client shortly before the timeout, and the client's pong keeps an idle connection open. Set sendPings: false to disable this. idleTimeout: 0 disables both the timeout and the automatic pings.
Bun also closes a WebSocket connection, with code 1009, if it receives a message larger than 16 MB. Configure this with the maxPayloadLength parameter. For compressed messages, the limit applies to the decompressed size.
Bun.serve({
fetch(req, server) {}, // upgrade logic
websocket: {
maxPayloadLength: 1024 * 1024, // 1 MB
},
});| Option | Default | Range | 0 means |
|---|---|---|---|
idleTimeout | 120 s | 0 to 960 | No timeout, no automatic pings |
maxPayloadLength | 16 MB | 0 to 2^32 - 1 | Only empty messages are accepted |
backpressureLimit | 16 MB | 0 to 2^32 - 1 | No limit |
Connect to a WebSocket server#
Bun implements the WebSocket class. To create a WebSocket client that connects to a ws:// or wss:// server, create an instance of WebSocket, as you would in the browser.
const socket = new WebSocket("ws://localhost:3000");
// With subprotocol negotiation
const socket2 = new WebSocket("ws://localhost:3000", ["soap", "wamp"]);Browsers send cookies set on the page along with the WebSocket upgrade request. This is a standard feature of the WebSocket API.
In Bun, the second argument can also be an options object. This is a Bun-specific extension of the WebSocket standard. It does not work in browsers.
const socket = new WebSocket("wss://localhost:3000", {
headers: { Authorization: "Bearer token" }, // custom headers for the upgrade request
protocols: ["soap", "wamp"], // subprotocols to offer
tls: { ca: Bun.file("./ca.pem") }, // TLS options, as in fetch()
proxy: "http://proxy.example.com:8080", // HTTP or HTTPS proxy
perMessageDeflate: false, // don't offer compression (default: true)
});With an https:// proxy, tls applies to both the proxy and the server. To configure them separately, pass proxy.tls. The proxy then uses only proxy.tls, and the server uses only tls. serverName and checkServerIdentity are never shared: the ones in tls are for the server, and the ones in proxy.tls are for the proxy.
const socket = new WebSocket("wss://example.com", {
proxy: {
url: "https://proxy.internal:8443",
tls: { ca: Bun.file("./proxy-ca.pem") }, // only for the connection to the proxy
},
});Bun's client also has ping(), pong() and terminate() methods, ping and pong events, and binaryType = "nodebuffer" (the default), which delivers binary messages as Buffer.
To add event listeners to the socket:
// message is received
socket.addEventListener("message", event => {});
// socket opened
socket.addEventListener("open", event => {});
// socket closed
socket.addEventListener("close", event => {});
// error handler
socket.addEventListener("error", event => {});socket.close(code, reason) sends a Close frame and sets readyState to CLOSING. The close event fires once the connection closes. Its code and reason come from the server's Close frame (1005 if the frame had no code). If the connection ended without a Close frame, code is 1006. If the server hasn't closed the connection after 30 seconds (BUN_CONFIG_WS_CLOSE_TIMEOUT), Bun closes it. socket.terminate() closes it immediately.
Backpressure#
The browser WebSocket API has no way to slow down a peer that sends faster than you consume: incoming messages are buffered in memory without bound. Bun adds pause() and resume(), which stop and restart reads from the underlying socket so the sender sees TCP backpressure instead. This is a Bun-specific extension. It does not work in browsers.
const socket = new WebSocket("ws://localhost:3000");
socket.addEventListener("message", event => {
if (!file.write(event.data)) {
// The file's buffer is full: stop reading until it drains.
socket.pause();
file.once("drain", () => socket.resume());
}
});pause() and resume() return true when they took effect (or will, once a connecting socket opens) and false when there is no socket to act on; socket.isPaused reports the current state. Messages already decoded when pause() is called may still be delivered. The ws package's pause(), resume(), and isPaused map to the same methods.
A paused client doesn't read from the socket, so until resume() it doesn't reply to pings or notice a Close frame or a disconnect. send(), ping(), and pong() still work. If a pause can outlast the server's idle timeout (120 seconds by default in Bun.serve), send a frame on a timer.
socket.pause();
const heartbeat = setInterval(() => socket.pong(), 30_000);
file.once("drain", () => {
clearInterval(heartbeat);
socket.resume();
});Reference#
See the API reference for WebSocketHandler (the websocket option), ServerWebSocket, Server and the WebSocket client.