WebSocket and realtime

Connection management, pub/sub, backpressure, and keepalive with Bun.serve's native WebSocket

Last updated on

When to use it

  • Chat, collaborative editing, live dashboards, agent progress streaming: Bun's native WebSocket is a first-class choice.
  • Need Socket.IO room semantics, auto-reconnect, and fallbacks: the ws or Socket.IO packages also run on Bun, but broadcasting is then up to the client library — nothing replaces server.publish's native pub/sub.
  • Targeting Lambda, Cloudflare Workers, and similar platforms: this page's shape doesn't apply — see Serverless and edge.

Minimal echo server

server.ts
const server = Bun.serve({
  port: 3000,
  fetch(req, server) {
    if (server.upgrade(req)) return; // upgraded; no Response to return
    return new Response('WebSocket endpoint', { status: 426 });
  },
  websocket: {
    open(ws) {
      ws.send('welcome');
    },
    message(ws, message) {
      ws.send(message); // echo
    },
    close(ws, code, reason) {
      console.log('closed', code, reason);
    },
  },
});

Auth and connection context

The upgrade starts as a plain HTTP request — authenticate before upgrading; inject context via data, readable in every handler afterwards:

// Your project's auth: validate the Cookie/Token, return the user ID or null
function authenticate(req: Request): string | null {
  return req.headers.get('x-demo-user'); // demo only; use real session validation in production
}

Bun.serve<{ userId: string }>({
  fetch(req, server) {
    const userId = authenticate(req);
    if (!userId) {
      return new Response('unauthorized', { status: 401 });
    }
    if (server.upgrade(req, { data: { userId } })) return;
    return new Response('WebSocket endpoint', { status: 426 });
  },
  websocket: {
    // the data property types ws.data across handlers
    data: {} as { userId: string },
    open(ws) {
      ws.subscribe(`user:${ws.data.userId}`);
    },
    message(ws, message) { /* ... */ },
  },
});

Pub/Sub

APISemantics
ws.subscribe(topic) / ws.unsubscribe(topic)Manage one connection's topic subscriptions
ws.publish(topic, data)Send to all subscribers of the topic except self
server.publish(topic, data)Send to all subscribers of the topic including self
ws.send(data)Send to this connection only; returns bytes sent

Always check ws.send's return value: 0 means dropped due to a connection problem, -1 means enqueued but backpressured. For high-frequency pushes, use backpressureLimit and closeOnBackpressureLimit to choose between buffering and disconnecting slow consumers — don't back slow clients with unbounded memory.

In-memory broadcast is per-process

server.publish pub/sub lives in the current process's memory. With multiple replicas, cross-replica messages need an external bus such as Redis Pub/Sub — see the Redis boundaries in Data and storage.

Idle timeout and keepalive

Bun closes WebSocket connections idle for 120 seconds by default (idleTimeout is configurable), but "idle" includes protocol-level pings: Bun defaults to sendPings: true, sends pings automatically, and standard clients answer pongs automatically — so a healthy receive-only connection is not dropped for silence. The cases you actually need to handle:

  1. Proxy links have their own idle timeouts: Nginx, Cloudflare, and platform LBs each cut idle connections, so the application layer should still design a 30–60s heartbeat and auto-reconnect around the shortest link;
  2. Zombie peers: with half-open TCP connections even pings may go unanswered — idleTimeout is the backstop, and clients must reconnect and restore subscriptions;
  3. Only with sendPings disabled or non-standard clients do you need periodic ws.send keepalives of your own.

Production boundaries

  • Cap single-message size with maxPayloadLength; validate message bodies as untrusted input.
  • Log auth outcomes for upgrade requests, never tokens.
  • On graceful shutdown, actively ws.close() connections and stop accepting new upgrades — pair with the SIGTERM flow in Containers and Kubernetes.
  • Load-test connection count, messages per second, broadcast fan-out, and memory — not single-connection echo latency.

Acceptance

  1. Unauthenticated requests get 401; no anonymous post-upgrade connections exist;
  2. Slow consumers trigger the backpressure policy without memory growing with connections;
  3. Clients auto-reconnect and restore subscriptions across the 120s idle boundary;
  4. Multi-replica setups receive cross-replica messages through the external bus.

Official references: Bun WebSocket, Bun.serve.