---
title: WebSockets
description: 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.

<Info>

**⚡️ 7x more throughput**

Bun's WebSockets are fast. For a [simple chatroom](https://github.com/aphrody-labs/bun/tree/main/bench/websocket-server/README.md) on Linux x64, Bun can handle 7x more messages per second than Node.js + [`"ws"`](https://github.com/websockets/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](https://github.com/uNetworking/uWebSockets).

</Info>

---

## Start a WebSocket server

The following server, built with `Bun.serve`, [upgrades](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism) every incoming request to a WebSocket connection in the `fetch` handler. You declare the socket handlers in the `websocket` parameter.

```ts server.ts icon="/icons/typescript.svg"
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:

```ts server.ts icon="/icons/typescript.svg"
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.

<Accordion title="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.

</Accordion>

The first argument to each handler is the `ServerWebSocket` instance handling the event. The `ServerWebSocket` class is a fast, Bun-native implementation of [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) with some additional features.

```ts server.ts icon="/icons/typescript.svg"
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.

```ts server.ts icon="/icons/typescript.svg" focus={4-6}
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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism). To attach additional `headers` to this `Response`, pass them to `server.upgrade()`.

{/* prettier-ignore */}
```ts server.ts icon="/icons/typescript.svg"
Bun.serve({
  async fetch(req, server) {
    const sessionId = await generateSessionId();
    server.upgrade(req, {
      headers: { // [!code ++]
        "Set-Cookie": `SessionId=${sessionId}`, // [!code ++]
      }, // [!code ++]
    });
  },
  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.

```ts server.ts icon="/icons/typescript.svg"
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,
      });
    },
  },
});
```

<Info>
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](https://github.com/microsoft/TypeScript/issues/26242).
</Info>

To connect to this server from the browser, create a new `WebSocket`.

```js browser.js icon="file-code"
const socket = new WebSocket("ws://localhost:3000/chat");

socket.addEventListener("message", event => {
  console.log(event.data);
});
```

<Warning>
**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](https://portswigger.net/web-security/websockets/cross-site-websocket-hijacking)). Check the `Origin` header against an allowlist before calling `server.upgrade()`.

</Warning>

### 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](https://en.wikipedia.org/wiki/MQTT) and [Redis Pub/Sub](https://redis.io/topics/pubsub).

```ts server.ts icon="/icons/typescript.svg"
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.

```ts
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](https://websockets.readthedocs.io/en/stable/topics/compression.html) with the `perMessageDeflate` parameter.

```ts server.ts icon="/icons/typescript.svg"
Bun.serve({
  fetch(req, server) {}, // upgrade logic
  websocket: {
    perMessageDeflate: true, // [!code ++]
  },
});
```

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.

```ts
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.

```ts
server.publish("the-group-chat", "Hello world"); // compressed
server.publish("the-group-chat", "Hello world", false); // uncompressed
```

Clients that didn't negotiate compression always receive uncompressed messages.

To configure each direction separately, pass an object.

```ts server.ts icon="/icons/typescript.svg"
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 backpressure
- `0` — The message was dropped because the connection is closed or already has more than `backpressureLimit` bytes buffered
- `1+` — 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.

```ts
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.

```ts
ws.close(); // 1000
ws.close(1011, "Something went wrong");
ws.terminate(); // no Close frame
```

- Clients only accept `1000`-`1003`, `1007`-`1014` and `3000`-`4999`. A browser that receives any other code reports `1006`.
- `0`, `1005` and `1006` send a Close frame with no code, so you can forward the code from a `close` handler 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.

```ts
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`.

```ts
Bun.serve({
  fetch(req, server) {}, // upgrade logic
  websocket: {
    idleTimeout: 60, // 60 seconds  // [!code ++]
  },
});
```

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.

```ts
Bun.serve({
  fetch(req, server) {}, // upgrade logic
  websocket: {
    maxPayloadLength: 1024 * 1024, // 1 MB  // [!code ++]
  },
});
```

| 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.

```ts
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._

```ts
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.

```ts
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:

```ts
// 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`](/runtime/environment-variables)), 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._

```ts
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.

```ts
socket.pause();
const heartbeat = setInterval(() => socket.pong(), 30_000);
file.once("drain", () => {
  clearInterval(heartbeat);
  socket.resume();
});
```

---

## Reference

See the API reference for [`WebSocketHandler`](https://bun.com/reference/bun/WebSocketHandler) (the `websocket` option), [`ServerWebSocket`](https://bun.com/reference/bun/ServerWebSocket), [`Server`](https://bun.com/reference/bun/Server) and the [`WebSocket`](https://bun.com/reference/globals/WebSocket) client.
