A WebSocket server using Bun.serve's built-in upgrade
Published December 25, 2026
export const wsServer = Bun.serve({
fetch(req, server) {
return server.upgrade(req) ? undefined : new Response("Upgrade failed", { status: 500 });
},
websocket: {
open(ws) {
ws.subscribe("room");
},
message(ws, message) {
wsServer.publish("room", `${message}`);
},
},
});
console.log(`WebSocket server listening on ${wsServer.url}`);What
server.upgrade(req) inside a Bun.serve() fetch handler promotes an HTTP connection to a
WebSocket. A single websocket: { open, message, close } object handles every connection —
Bun doesn't ask for a new listener per socket the way the browser-style WebSocket class does.
Why it matters
Node has no built-in WebSocket server; the standard path is installing ws and wiring it up
next to (or in front of) your HTTP server. Bun's WebSocket support is built into Bun.serve()
itself, sits on top of the same event loop as your HTTP routes, and — per Bun's own benchmarks —
handles roughly 7x the messages per second of ws on Node for a comparable chatroom workload.
The handler shape is also deliberately different from client-side WebSocket: instead of
attaching onmessage/onopen per socket (which means a new closure and listener per
connection), Bun takes one handler object for the whole server and passes the specific socket in
as an argument — cheaper when a server holds thousands of connections open at once.
How it works
fetch(req, server)runs for every incoming HTTP request, including the one requesting a WebSocket upgrade.server.upgrade(req)returnstrueif the upgrade succeeded; returningundefinedafterward tells Bun this request is now a socket, not an HTTP response.- A failed upgrade (wrong headers, not actually a WebSocket handshake) makes
.upgrade()returnfalse, so the fallbackResponseonly fires for genuinely bad requests. open(ws)runs once per connection;ws.subscribe("room")joins Bun's built-in pub/sub topic system rather than manually tracking a list of open sockets.message(ws, message)runs on every incoming message;server.publish("room", ...)broadcasts to every socket subscribed to that topic in one call, including (by default) every socket except the sender.
Gotchas
serverinside thewebsockethandlers refers to the outerBun.serve()return value, which closes over the whole module — theserverparameter passed intofetchis the same object, just named identically in that inner scope..publish()on theServerinstance broadcasts to all subscribers;.publish()on aServerWebSocketinstance broadcasts to everyone except itself — picking the wrong one either echoes a message back to its sender or silently drops it for them.- Idle WebSocket connections close after 120 seconds by default (
idleTimeouton thewebsocketobject) — longer than HTTP's 10-second default, but still worth raising explicitly for a genuinely long-lived connection.
Related
- The client-side
WebSocketclass (new WebSocket("ws://...")) is unchanged from the browser-standard API and works the same way connecting to this server. - Server-Sent Events via a streaming
Responseare a simpler one-way alternative when the client never needs to send messages back.