WebSocket

Nitro provides cross-platform WebSocket support powered by CrossWS and H3.

WebSocket enables real-time, bidirectional communication between client and server. Nitro's WebSocket integration works across all supported deployment targets including Node.js, Bun, Deno, and Cloudflare Workers.

Read more in CrossWS Documentation.

Enable WebSocket

Enable WebSocket support in your Nitro configuration:

import { defineConfig } from "nitro";

export default defineConfig({
  features: {
    websocket: true,
  },
});

Usage

Create a WebSocket handler using defineWebSocketHandler and export it from a route file. WebSocket handlers follow the same file-based routing as regular request handlers.

routes/_ws.ts
import { defineWebSocketHandler } from "nitro";

export default defineWebSocketHandler({
  open(peer) {
    console.log("Connected:", peer.id);
  },
  message(peer, message) {
    console.log("Message:", message.text());
    peer.send("Hello from server!");
  },
  close(peer, details) {
    console.log("Disconnected:", peer.id, details.code, details.reason);
  },
  error(peer, error) {
    console.error("Error:", error);
  },
});
You can use any route path for WebSocket handlers. For example, routes/chat.ts handles WebSocket connections on /chat.

Connecting from the client

Use the browser's WebSocket API to connect:

const ws = new WebSocket("ws://localhost:3000/_ws");

ws.addEventListener("open", () => {
  console.log("Connected!");
  ws.send("Hello from client!");
});

ws.addEventListener("message", (event) => {
  console.log("Received:", event.data);
});

Hooks

WebSocket handlers accept the following lifecycle hooks:

upgrade

Called before the WebSocket connection is established. Use it to authenticate requests, set the namespace, or attach context data to the peer.

routes/chat.ts
import { defineWebSocketHandler } from "nitro";

export default defineWebSocketHandler({
  upgrade(request) {
    const url = new URL(request.url);
    const token = url.searchParams.get("token");
    if (!isValidToken(token)) {
      throw new Response("Unauthorized", { status: 401 });
    }
    return {
      context: { userId: getUserId(token) },
    };
  },
  open(peer) {
    console.log("User connected:", peer.context.userId);
  },
  // ...
});

The upgrade hook can return an object with:

PropertyTypeDescription
headersHeadersInitResponse headers to include in the upgrade response
namespacestringOverride the pub/sub namespace for this connection
contextobjectData attached to peer.context

Throw a Response to reject the upgrade.

open

Called when a WebSocket connection is established and the peer is ready to send and receive messages.

open(peer) {
  peer.send("Welcome!");
}

message

Called when a message is received from a peer.

message(peer, message) {
  const text = message.text();
  const data = message.json();
}

close

Called when a WebSocket connection is closed. Receives a details object with optional code and reason.

close(peer, details) {
  console.log(`Closed: ${details.code} - ${details.reason}`);
}

error

Called when an error occurs on the WebSocket connection.

error(peer, error) {
  console.error("WebSocket error:", error);
}

Peer

The peer object represents a connected WebSocket client. It is available in all hooks except upgrade.

Properties

PropertyTypeDescription
idstringUnique identifier for this peer
namespacestringPub/sub namespace this peer belongs to
contextobjectArbitrary context data set during upgrade
requestRequestThe original upgrade request
peersSet<Peer>All connected peers in the same namespace
topicsSet<string>Topics this peer is subscribed to
remoteAddressstring?Client IP address (adapter-dependent)
websocketWebSocketThe underlying WebSocket instance

Methods

peer.send(data, options?)

Send a message directly to this peer. Accepts strings, objects (serialized as JSON), or binary data.

peer.send("Hello!");
peer.send({ type: "greeting", text: "Hello!" });

peer.subscribe(topic)

Subscribe this peer to a pub/sub topic.

peer.subscribe("notifications");

peer.unsubscribe(topic)

Unsubscribe this peer from a topic.

peer.unsubscribe("notifications");

peer.publish(topic, data, options?)

Broadcast a message to all peers subscribed to a topic within the same namespace. The publishing peer does not receive the message.

peer.publish("chat", { user: "Alice", text: "Hello everyone!" });

peer.close(code?, reason?)

Gracefully close the WebSocket connection.

peer.close(1000, "Normal closure");

peer.terminate()

Immediately terminate the connection without sending a close frame.

Message

The message object in the message hook provides methods to read the incoming data in different formats.

MethodReturn TypeDescription
text()stringMessage as a UTF-8 string
json()TMessage parsed as JSON
uint8Array()Uint8ArrayMessage as a byte array
arrayBuffer()ArrayBufferMessage as an ArrayBuffer
blob()BlobMessage as a Blob
message(peer, message) {
  // Parse as text
  const text = message.text();

  // Parse as typed JSON
  const data = message.json<{ type: string; payload: unknown }>();
}

Pub/Sub

Pub/sub (publish/subscribe) enables broadcasting messages to groups of connected peers through topics. Peers subscribe to topics and receive messages published to those topics.

routes/chat.ts
import { defineWebSocketHandler } from "nitro";

export default defineWebSocketHandler({
  open(peer) {
    peer.subscribe("chat");
    peer.publish("chat", { system: `${peer} joined the chat` });
    peer.send({ system: "Welcome to the chat!" });
  },
  message(peer, message) {
    // Broadcast to all other subscribers
    peer.publish("chat", {
      user: peer.toString(),
      text: message.text(),
    });
    // Echo back to sender
    peer.send({ user: "You", text: message.text() });
  },
  close(peer) {
    peer.publish("chat", { system: `${peer} left the chat` });
  },
});
peer.publish() sends the message to all subscribers of the topic except the publishing peer. Use peer.send() to also send to the publisher.

Namespaces

Namespaces provide isolated pub/sub groups for WebSocket connections. Each peer belongs to one namespace, and peer.publish() only broadcasts to peers within the same namespace.

By default, the namespace is derived from the request URL pathname. This works naturally with dynamic routes — each path gets its own isolated namespace:

routes/rooms/[room].ts
import { defineWebSocketHandler } from "nitro";

export default defineWebSocketHandler({
  open(peer) {
    peer.subscribe("messages");
    peer.publish("messages", `${peer} joined ${peer.namespace}`);
  },
  message(peer, message) {
    // Only reaches peers in the same room
    peer.publish("messages", `${peer}: ${message.text()}`);
  },
  close(peer) {
    peer.publish("messages", `${peer} left`);
  },
});

In this example, clients connecting to /rooms/game are isolated from clients connecting to /rooms/lobby — each path is its own namespace.

To override the default namespace, return a custom namespace from the upgrade hook:

routes/chat.ts
import { defineWebSocketHandler } from "nitro";

export default defineWebSocketHandler({
  upgrade(request) {
    // Group connections by a query parameter instead of the pathname
    const url = new URL(request.url);
    const channel = url.searchParams.get("channel") || "general";
    return {
      namespace: `chat:${channel}`,
    };
  },
  open(peer) {
    peer.subscribe("messages");
    peer.publish("messages", `${peer} joined`);
  },
  message(peer, message) {
    peer.publish("messages", `${peer}: ${message.text()}`);
  },
  close(peer) {
    peer.publish("messages", `${peer} left`);
  },
});

Server-Sent Events (SSE)

Server-Sent Events provide a simpler alternative when you only need server-to-client streaming. Unlike WebSockets, SSE uses standard HTTP and supports automatic reconnection.

routes/sse.ts
import { defineHandler } from "nitro";
import { createEventStream } from "nitro/h3";

export default defineHandler((event) => {
  const stream = createEventStream(event);

  const interval = setInterval(async () => {
    await stream.push(`Message @ ${new Date().toLocaleTimeString()}`);
  }, 1000);

  stream.onClosed(() => {
    clearInterval(interval);
  });

  return stream.send();
});

Connect from the client using the EventSource API:

const source = new EventSource("/sse");

source.onmessage = (event) => {
  console.log(event.data);
};

Structured messages

SSE messages support optional id, event, and retry fields:

routes/events.ts
import { defineHandler } from "nitro";
import { createEventStream } from "nitro/h3";

export default defineHandler((event) => {
  const stream = createEventStream(event);
  let id = 0;

  const interval = setInterval(async () => {
    await stream.push({
      id: String(id++),
      event: "update",
      data: JSON.stringify({ value: Math.random() }),
      retry: 3000,
    });
  }, 1000);

  stream.onClosed(() => {
    clearInterval(interval);
  });

  return stream.send();
});
Read more in H3 Documentation.