Fresh logo

WebSockets

Fresh provides built-in helpers for upgrading HTTP connections to WebSockets. There are two main approaches depending on your use case.

Quick start with app.ws()

The simplest way to add a WebSocket endpoint:

Typescript main.ts
import { App } from "fresh";

const app = new App()
  .ws("/ws", {
    open(socket) {
      console.log("Client connected");
    },
    message(socket, event) {
      socket.send(`Echo: ${event.data}`);
    },
    close(socket, code, reason) {
      console.log("Client disconnected", code, reason);
    },
  });

app.ws(path, handlers) registers a GET route that automatically upgrades the request to a WebSocket connection and wires up your event handlers.

Using ctx.upgrade() in route handlers

For file-based routes or when you need more control, use ctx.upgrade() inside a GET handler.

Managed mode

Pass an event handlers object and receive the upgrade Response directly:

Typescript routes/api/ws.ts
import { define } from "@/utils.ts";

export const handlers = define.handlers({
  GET(ctx) {
    return ctx.upgrade({
      open(socket) {
        console.log("Client connected");
      },
      message(socket, event) {
        socket.send(`Echo: ${event.data}`);
      },
      close(socket, code, reason) {
        console.log("Disconnected", code, reason);
      },
      error(socket, event) {
        console.error("WebSocket error", event);
      },
    });
  },
});

Bare mode

Call ctx.upgrade() without arguments to get the raw WebSocket object. This is useful when you need to store the socket in a shared structure like a chat room or pub/sub registry:

Typescript routes/api/chat.ts
import { define } from "@/utils.ts";

const clients = new Set<WebSocket>();

export const handlers = define.handlers({
  GET(ctx) {
    const { socket, response } = ctx.upgrade();

    socket.onopen = () => {
      clients.add(socket);
    };
    socket.onmessage = (event) => {
      // Broadcast to all connected clients
      for (const client of clients) {
        if (client.readyState === WebSocket.OPEN) {
          client.send(event.data);
        }
      }
    };
    socket.onclose = () => {
      clients.delete(socket);
    };

    return response;
  },
});

Upgrade options

Both modes accept an options object to configure the underlying WebSocket:

Typescript  
// Managed mode — pass handlers first, then options
ctx.upgrade(handlers, {
  idleTimeout: 60, // close if no ping received within 60s (default: 120)
  protocol: "graphql-ws", // sub-protocol to negotiate
});

// Bare mode — pass options without handlers to get the raw socket back
const { socket, response } = ctx.upgrade({ idleTimeout: 60 });

How does Fresh tell the two calls apart? The first argument is treated as managed-mode handlers when it contains at least one function-valued handler key (open, message, close, or error). A plain options object only has non-function fields (idleTimeout, protocol), so it always enters bare mode.

The same options can be passed to app.ws():

Typescript  
app.ws("/ws", handlers, { idleTimeout: 60 });

app.ws() always uses managed mode. For bare-mode access to the raw socket, use app.get() with ctx.upgrade() instead.

Error handling

If a non-WebSocket request hits a WebSocket route, ctx.upgrade() throws an HttpError(400) with the message “Expected a WebSocket upgrade request”. This is handled automatically by Fresh’s error pipeline and returns a 400 response.

Handler reference

All handler callbacks are optional:

Callback Arguments Description
open (socket) Connection established
message (socket, event) Message received (event.data contains the payload)
close (socket, code, reason) Connection closed
error (socket, event) Error occurred on the connection

Client-side example

Connect from the browser:

Typescript  
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${protocol}//${location.host}/ws`);

ws.onopen = () => {
  ws.send("Hello from the client!");
};

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