How to Use WebSockets in Bun for Real-Time Bidirectional Communication
Bun provides a native WebSocket implementation built on uWebSockets that lets you upgrade HTTP requests to persistent connections using Bun.serve() and handle bidirectional messaging through a shared handler object.
Bun's WebSocket support is engineered for high-performance real-time applications. According to the oven-sh/bun source code, the runtime exposes uWebSockets through a JavaScript API that minimizes memory overhead while maximizing throughput. This guide explains how to implement bidirectional communication using Bun's native ServerWebSocket class and the Bun.serve() infrastructure.
Creating a WebSocket Server with Bun.serve()
The foundation of Bun's WebSocket support lies in the Bun.serve() API. When you configure a server, you provide a fetch handler to process HTTP requests and a websocket object containing event handlers. The upgrade mechanism that transforms HTTP into WebSocket connections is implemented in src/http/websocket_http_client.zig, while the core socket logic resides in src/http/websocket.zig.
Upgrading HTTP Requests to WebSockets
To establish a WebSocket connection, call server.upgrade(req, options?) inside your fetch handler. If the upgrade succeeds, the function returns true and you should return undefined (or no response) to let Bun handle the protocol switch. If it fails, return a standard HTTP Response.
// server.ts
Bun.serve({
fetch(req, server) {
// Upgrade any request that reaches this point.
if (server.upgrade(req)) return; // success → no Response
return new Response("Upgrade failed", { status: 500 });
},
websocket: {
// Echo every incoming message back to the sender.
message(ws, message) {
ws.send(message);
},
},
});
Source reference: This pattern is documented in docs/runtime/http/websockets.mdx and implemented in the Zig upgrade handler at src/http/websocket_http_client.zig.
The Shared Handler Architecture
Unlike other runtimes that instantiate new handler objects per connection, Bun uses a shared handler object defined in the websocket property of Bun.serve(). This design, optimized in src/http/websocket.zig, keeps memory overhead low even with thousands of concurrent connections—only one set of callbacks exists, and each ServerWebSocket instance holds minimal per-connection state.
Managing WebSocket Connections and Data
Once upgraded, connections are managed through the ServerWebSocket interface. This class provides methods for sending data, managing subscriptions, and inspecting connection health.
Typed Context Data
You can attach contextual data during the upgrade process using the data option. This data becomes available on the ws object inside all lifecycle handlers, enabling type-safe access to user information or room identifiers.
type WSData = { userId: string; room: string };
Bun.serve({
fetch(req, server) {
const token = new Bun.CookieMap(req.headers.get("cookie")!).get("auth");
server.upgrade(req, {
data: { userId: token, room: new URL(req.url).searchParams.get("room")! },
});
return undefined; // upgrade handled
},
websocket: {
data: {} as WSData, // tells TypeScript the shape of ws.data
async message(ws, msg) {
// ws.data is strongly typed here
await saveMessage(ws.data.userId, ws.data.room, String(msg));
},
},
});
Source reference: The contextual data pattern is documented in docs/runtime/http/websockets.mdx (lines 25-31).
Connection Lifecycle Events
The shared handler object supports five key events defined in src/http/websocket.zig:
open(ws): Fired when the connection is established.message(ws, message): Fired when the client sends data (string or Buffer).close(ws, code, reason): Fired when the connection closes.drain(ws): Fired when the socket is ready for more data after back-pressure.error(ws, error): Fired when a protocol error occurs.
Advanced WebSocket Features in Bun
Bun's implementation includes production-ready features like pub/sub broadcasting, compression, and back-pressure management, all leveraging uWebSockets' native capabilities.
Pub/Sub Broadcasting with Topics
Bun supports topic-based messaging via the subscribe() and publish() methods. This enables efficient broadcasting without iterating through connections manually. The pub/sub logic uses uWebSockets' built-in implementation as exposed through src/http/websocket.zig.
const server = Bun.serve({
fetch(req, server) {
if (new URL(req.url).pathname === "/chat") {
const username = getUsernameFromReq(req);
server.upgrade(req, { data: { username } });
return undefined;
}
return new Response("Not a WebSocket endpoint");
},
websocket: {
data: {} as { username: string },
open(ws) {
ws.subscribe("room");
server.publish("room", `${ws.data.username} joined`);
},
message(ws, msg) {
server.publish("room", `${ws.data.username}: ${msg}`);
},
close(ws) {
ws.unsubscribe("room");
server.publish("room", `${ws.data.username} left`);
},
},
});
console.log(`Listening on ${server.hostname}:${server.port}`);
Per-Message Compression
Enable compression via the perMessageDeflate option in the server configuration, or control it per-message via the second argument to send(). The compression handling is wired through uWebSockets in src/http/websocket_client.zig.
Bun.serve({
websocket: {
perMessageDeflate: true, // enables default compression
message(ws, msg) {
ws.send(msg, true); // compress this individual message
},
},
});
Back-Pressure and Flow Control
The send() method returns a numeric status indicating the operation result: -1 means the data was enqueued due to back-pressure, 0 means it was dropped, and positive values indicate the number of bytes sent. Monitor the drain event to resume sending when the buffer clears.
Bun.serve({
websocket: {
message(ws, msg) {
const result = ws.send(msg);
if (result === -1) {
// The socket buffer is full – stop sending until 'drain' fires
console.warn("Back‑pressure, pausing sends");
}
},
drain(ws) {
console.log("Socket ready for more data");
},
},
});
Source reference: Back-pressure semantics are documented at lines 69-76 of docs/runtime/http/websockets.mdx and implemented in src/http/websocket.zig.
Connecting from the Client Side
Bun ships with a WebSocket class that matches the browser API but includes a headers option for non-browser use cases. The implementation lives in src/bake/client/websocket.ts and wraps the native client code from src/http/websocket_client.zig.
// client.ts (runs with `bun run client.ts`)
const socket = new WebSocket("ws://localhost:3000/chat", {
headers: { "X-Auth": "my-secret-token" }, // Bun‑only extension
});
socket.addEventListener("open", () => console.log("Connected"));
socket.addEventListener("message", e => console.log("←", e.data));
socket.addEventListener("close", () => console.log("Disconnected"));
socket.addEventListener("error", err => console.error("❌", err));
// Send a message
socket.send("Hello from Bun!");
Summary
- Bun's WebSocket implementation is built on the high-performance uWebSockets C++ library and exposed through
Bun.serve(). - Use
server.upgrade(req)in the fetch handler to promote HTTP connections to WebSockets, as implemented insrc/http/websocket_http_client.zig. - The shared handler pattern in
src/http/websocket.zigkeeps memory usage low for thousands of concurrent connections. - Return values from
ws.send()indicate back-pressure status (-1for queued,0for dropped, positive for bytes sent). - Topic-based pub/sub is supported natively via
ws.subscribe()andserver.publish()using uWebSockets' built-in mechanisms. - Per-message compression is available through the
perMessageDeflateoption or per-send boolean flags.
Frequently Asked Questions
How do I upgrade an HTTP request to a WebSocket in Bun?
Call server.upgrade(req, options?) inside the fetch handler of your Bun.serve() configuration. If the upgrade succeeds, return undefined to complete the handshake; otherwise return a Response object. The upgrade logic is implemented in src/http/websocket_http_client.zig and exposed through the Server interface in src/http/websocket.zig.
What is the difference between Bun's ServerWebSocket and the standard WebSocket API?
ServerWebSocket is Bun's server-side class (defined in src/http/websocket.zig) that wraps uWebSockets and provides methods like publish() and subscribe() for pub/sub, plus numeric back-pressure reporting. The standard WebSocket class (implemented in src/bake/client/websocket.ts) is for clients and matches the browser API with Bun-specific extensions like custom headers.
How does Bun handle WebSocket back-pressure?
The send() method returns a number indicating the send status: -1 means the data was enqueued due to back-pressure, 0 means it was dropped, and positive values indicate bytes sent. You should monitor the drain event to resume sending when the buffer clears, as implemented in the Zig layer of src/http/websocket.zig.
Can I use compression with Bun WebSockets?
Yes. Enable perMessageDeflate: true in the websocket configuration for default compression, or pass true as the second argument to ws.send(message, compress) for per-message control. The compression handling is wired through uWebSockets in src/http/websocket_client.zig.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →