10LOC
Bunintermediate

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) returns true if the upgrade succeeded; returning undefined afterward tells Bun this request is now a socket, not an HTTP response.
  • A failed upgrade (wrong headers, not actually a WebSocket handshake) makes .upgrade() return false, so the fallback Response only 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

  • server inside the websocket handlers refers to the outer Bun.serve() return value, which closes over the whole module — the server parameter passed into fetch is the same object, just named identically in that inner scope.
  • .publish() on the Server instance broadcasts to all subscribers; .publish() on a ServerWebSocket instance 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 (idleTimeout on the websocket object) — longer than HTTP's 10-second default, but still worth raising explicitly for a genuinely long-lived connection.

Related

  • The client-side WebSocket class (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 Response are a simpler one-way alternative when the client never needs to send messages back.