Bun.serve() has first-class support for server-side WebSockets with on-the-fly compression, TLS, and a built-in publish-subscribe API — all powered by uWebSockets.
Bun’s WebSocket server handles ~700,000 messages/second with 16 clients on Linux x64, compared to ~100,000 messages/second with Node.js + ws.
Basic WebSocket server
Pass a websocket object to Bun.serve(). Upgrade HTTP connections to WebSocket in the fetch handler by calling server.upgrade(req).
Bun.serve({
fetch(req, server) {
if (server.upgrade(req)) return; // upgrade succeeded — no Response needed
return new Response("Not a WebSocket request", { status: 426 });
},
websocket: {
open(ws) {
ws.send("Connected!");
},
message(ws, msg) {
ws.send(msg); // echo back
},
close(ws) {
console.log("Connection closed");
},
},
});
The four lifecycle handlers are:
| Handler | Called when |
|---|
open(ws) | Client connects |
message(ws, message) | Client sends a message |
close(ws, code, reason) | Connection is closed |
drain(ws) | Backpressure clears and socket is ready to send |
Handlers are declared once per server and shared across all connections. This keeps memory usage flat regardless of how many clients are connected.
Sending messages
ws.send() accepts strings, ArrayBuffer, and TypedArray/DataView values. It returns a status number:
1+ — bytes sent
-1 — message enqueued (backpressure)
0 — message dropped (connection issue)
websocket: {
message(ws, message) {
ws.send("Hello world"); // string
ws.send(new Uint8Array([1, 2, 3])); // binary
ws.send(message, true); // with per-message compression
},
},
Contextual data
Attach arbitrary data to a connection during upgrade. The data is available on ws.data inside all lifecycle handlers. Add a typed data property to the websocket object for full TypeScript inference.
type WebSocketData = {
userId: string;
channelId: string;
};
Bun.serve({
fetch(req, server) {
const url = new URL(req.url);
server.upgrade(req, {
data: {
userId: url.searchParams.get("userId") ?? "anonymous",
channelId: url.searchParams.get("channel") ?? "general",
},
});
return undefined;
},
websocket: {
// Declare the shape of ws.data for TypeScript
data: {} as WebSocketData,
open(ws) {
console.log(`${ws.data.userId} joined #${ws.data.channelId}`);
},
message(ws, message) {
console.log(`[${ws.data.channelId}] ${ws.data.userId}: ${message}`);
},
},
});
Attach headers to the 101 Switching Protocols response by passing a headers object to server.upgrade():
Bun.serve({
async fetch(req, server) {
const sessionId = crypto.randomUUID();
server.upgrade(req, {
headers: {
"Set-Cookie": `SessionId=${sessionId}; HttpOnly; Secure`,
},
});
return undefined;
},
websocket: {
message(ws, msg) { ws.send(msg); },
},
});
Pub/Sub
Bun provides a native topic-based publish-subscribe API. Sockets subscribe to named topics and publish messages that are broadcast to all other subscribers of that topic.
const server = Bun.serve({
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/chat") {
const username = url.searchParams.get("name") ?? "anonymous";
const success = server.upgrade(req, { data: { username } });
return success
? undefined
: new Response("WebSocket upgrade failed", { status: 400 });
}
return new Response("Hello");
},
websocket: {
data: {} as { username: string },
open(ws) {
ws.subscribe("the-group-chat");
server.publish("the-group-chat", `${ws.data.username} joined`);
},
message(ws, message) {
// Broadcast to all other subscribers (excluding sender)
server.publish("the-group-chat", `${ws.data.username}: ${message}`);
},
close(ws) {
ws.unsubscribe("the-group-chat");
server.publish("the-group-chat", `${ws.data.username} left`);
},
},
});
console.log(`Chat server running at ${server.url}`);
Call server.publish(topic, message) (on the Server object) to send to all subscribers including the calling socket. Call ws.publish(topic, message) to send to all subscribers except the calling socket.
Counting subscribers
Bun.serve({
fetch(req, server) {
const count = server.subscriberCount("the-group-chat");
return new Response(`${count} users online`);
},
websocket: {
message(ws) { ws.subscribe("the-group-chat"); },
},
});
Compression
Enable per-message deflate compression globally on the websocket handler:
Bun.serve({
websocket: {
perMessageDeflate: true,
message(ws, msg) { ws.send(msg); },
},
fetch(req, server) {
server.upgrade(req);
return undefined;
},
});
Compress individual messages by passing true as the second argument to ws.send():
ws.send("This message will be compressed", true);
For fine-grained control, configure compress and decompress separately:
Bun.serve({
websocket: {
perMessageDeflate: {
compress: "16KB",
decompress: "shared",
},
message(ws, msg) { ws.send(msg); },
},
fetch(req, server) {
server.upgrade(req);
return undefined;
},
});
Valid compressor values: "disable", "shared", "dedicated", "3KB" through "256KB".
Timeouts and limits
Bun.serve({
websocket: {
// Close idle connections after 60 s (default: 120 s)
idleTimeout: 60,
// Reject messages larger than 1 MB (default: 16 MB)
maxPayloadLength: 1024 * 1024,
// Close the connection when backpressure limit is exceeded
backpressureLimit: 512 * 1024,
closeOnBackpressureLimit: true,
message(ws, msg) { ws.send(msg); },
},
fetch(req, server) {
server.upgrade(req);
return undefined;
},
});
WebSocket client
Bun implements the standard browser WebSocket API. Connect to any ws:// or wss:// server:
const socket = new WebSocket("ws://localhost:3000");
socket.addEventListener("open", () => {
socket.send("Hello from client!");
});
socket.addEventListener("message", event => {
console.log("Received:", event.data);
});
socket.addEventListener("close", event => {
console.log("Disconnected:", event.code, event.reason);
});
socket.addEventListener("error", event => {
console.error("WebSocket error:", event);
});
Bun allows setting custom HTTP headers directly in the WebSocket constructor. This is a Bun-specific extension and does not work in browsers.
const socket = new WebSocket("wss://api.example.com/ws", {
headers: {
Authorization: "Bearer my-token",
"X-Client-Version": "2.0",
},
});
Subprotocol negotiation
const socket = new WebSocket("ws://localhost:3000", ["soap", "wamp"]);
Reference
interface WebSocketHandler<T = undefined> {
open?(ws: ServerWebSocket<T>): void | Promise<void>;
message(ws: ServerWebSocket<T>, message: string | Buffer): void | Promise<void>;
close?(ws: ServerWebSocket<T>, code: number, reason: string): void | Promise<void>;
drain?(ws: ServerWebSocket<T>): void | Promise<void>;
idleTimeout?: number; // seconds; default: 120
maxPayloadLength?: number; // bytes; default: 16 MB
backpressureLimit?: number; // bytes; default: 1 MB
closeOnBackpressureLimit?: boolean; // default: false
sendPings?: boolean; // default: true
publishToSelf?: boolean; // default: false
perMessageDeflate?:
| boolean
| {
compress?: boolean | WebSocketCompressor;
decompress?: boolean | WebSocketCompressor;
};
}
interface ServerWebSocket<T = undefined> {
readonly data: T;
readonly readyState: number;
readonly remoteAddress: string;
readonly subscriptions: string[];
send(message: string | ArrayBuffer | Uint8Array, compress?: boolean): number;
close(code?: number, reason?: string): void;
subscribe(topic: string): void;
unsubscribe(topic: string): void;
publish(topic: string, message: string | ArrayBuffer | Uint8Array, compress?: boolean): void;
isSubscribed(topic: string): boolean;
cork(cb: (ws: ServerWebSocket<T>) => void): void;
}