hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
Server Caller
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
Styling
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition
usePositioner
useDismiss
useFocusReturn

WebSockets#

hono-preact ships two WebSocket layers. The typed socket primitive (defineSocket + useSocket) gives you a declarative, type-safe duplex channel wired automatically to the framework's shared connection. The raw upgradeWebSocket export, imported from hono-preact, lets you register any hand-authored WS route in api.ts, following Hono's own upgradeWebSocket pattern. It works on both adapters: on Node it rides the framework's shared connection, and on Cloudflare it upgrades in the worker via a per-route WebSocketPair.

For the rules around the api.ts mount point and framework-reserved paths, see Composing Hono Middleware.

Choosing between sockets and live loaders#

NeedReach for
Client sends messages to the serverdefineSocket + useSocket
Server pushes data on mutations (pub/sub)Realtime Channels
Server streams data to a layout widgetLive Loaders

A socket is the right tool when the browser needs to send upstream, or when you want a persistent full-duplex channel. Server-to-client-only scenarios (a notification feed, a dashboard widget) are better served by SSE-backed live loaders, which require no upgrade plumbing.

On Node, a single HTTP connection is shared across the framework and all sockets. On Cloudflare a typed socket runs end to end: the worker guards the upgrade at the edge and forwards it to a per-connection Durable Object that runs the handler under the Hibernation API, so the same defineSocket works on both runtimes. Cross-connection fan-out (broadcasting to every connected client) is a separate concern: use Rooms, which coordinate many connections through one Durable Object per topic.

Typed sockets with defineSocket#

The typed socket primitive integrates with the framework's route table and build pipeline. The build strips the server implementation on the client side, replacing it with a lightweight descriptor, so the types flow without shipping handler code to the browser.

Server module#

Place the socket definition in a .server.ts module, inside a serverSockets export:

// src/chat.server.ts
import { defineSocket } from 'hono-preact';
import { requireSession } from './auth/session.js';

export const serverSockets = {
  chat: defineSocket<
    { text: string }, // messages the client sends
    { text: string; from: string }, // messages the server sends
    { name: string } // socket.data shape, seeded by the `data` factory below
  >({
    // Optional guard middleware. A failing guard closes the socket with 4403.
    use: [requireSession],

    // Edge factory: runs at the upgrade with the live Context; its result seeds
    // socket.data. Read cookies, headers, and middleware values here.
    data: (c) => ({ name: c.get('user').name }),

    open(socket) {
      // Return a teardown fn to run on close (Node only; see the note below).
      return () => {
        console.log(`${socket.data.name} disconnected`);
      };
    },

    message(socket, msg) {
      // Echo the message back to the same connection.
      socket.send({ text: msg.text, from: socket.data.name });
    },

    close(socket, ev) {
      console.log('closed', ev.code, ev.reason);
    },
  }),
};

open runs once per connection after the upgrade. It receives only the socket; read request-derived values (cookies, headers, middleware state) in the data factory, which runs at the upgrade with the full Hono Context and seeds socket.data. Returning a function from open registers a teardown that runs when the connection closes on Node; on Cloudflare the connection is hibernatable, so use close for cleanup that must run on both runtimes.

socket.data is seeded by the data factory at connect time and is read-only (Readonly<Data>). Declare its shape with the third type parameter: defineSocket<Incoming, Outgoing, Data>(). It is undefined by default (no data factory). On Cloudflare each event sees the connect-time value (the Durable Object hibernates between events). For Node-only mutable per-connection state, capture a closure variable inside open() instead.

Client component#

Import the serverSockets map from the .server module and call .useSocket on the entry:

// src/chat.tsx
import { serverSockets } from './chat.server.js';

export default function Chat() {
  const { send, status, lastMessage } = serverSockets.chat.useSocket({
    lastMessage: true,
    onMessage(msg) {
      console.log('received', msg.text);
    },
  });

  return (
    <div>
      <p>Status: {status}</p>
      {lastMessage && (
        <p>
          {lastMessage.from}: {lastMessage.text}
        </p>
      )}
      <button onClick={() => send({ text: 'hello' })}>Send</button>
    </div>
  );
}

The free useSocket(ref, opts) export also works and is equivalent; the method form is the idiomatic choice when the ref is a serverSockets entry.

useSocket manages the WebSocket lifecycle: it connects on mount, reconnects with exponential backoff on unexpected drops, and closes cleanly on unmount. send queues messages if the socket is not yet open, flushing them once the connection is established (up to 128 queued messages); beyond that cap, further sends while not open are dropped, with a dev-mode console warning rather than a silent loss. Toggling enabled to false on an open socket tears the connection down and status becomes 'closed'.

status is reactive and drives UI affordances:

ValueMeaning
'connecting'First connect attempt in progress
'open'Connection is established
'reconnecting'Connection dropped; backoff timer running
'closing'close() called; handshake in progress
'closed'Connection closed and will not reconnect

Guard model#

A socket's guard chain is composed in order: app-level use from defineApp({ use }), then the owning route's page-layer use, then the socket's own use array. A socket colocated with a route (its .server.ts sibling) inherits that route's use chain automatically, the same as a colocated loader or action.

serverRoute(r).socket(handler) binds a socket to a route explicitly instead of relying on colocation:

// src/server/chat/room-chat.server.ts
import { serverRoute } from 'hono-preact';

const route = serverRoute('/chat');

export const serverSockets = {
  chat: route.socket<{ text: string }, { text: string; from: string }>({
    // Inherits `/chat`'s page-layer `use` chain, wherever this file lives.
    message(socket, msg) {
      socket.send({ text: msg.text, from: 'someone' });
    },
  }),
};

The declared pattern is stamped on the def and validated at boot: a pattern that does not match the module's mount, names an unknown route, or targets a childless wildcard fails the boot rather than running the socket under an empty gate chain. A declared binding takes precedence over the module-mount derivation, so it also lets a route-bound socket live under the src/server registry organized by domain rather than beside its view. A bare defineSocket with no serverRoute binding, defined in a src/server registry module rather than colocated with a route, is route-independent: it only ever runs the app-level use and its own use. Add socket-specific authorization in the socket's own use array for concerns that only apply to the socket.

Binding to a route with :params opens a typed param wire too. useSocket requires a params option shaped from the route pattern, and the same validated values reach the edge data factory as its second argument:

// src/server/boards/board-chat.server.ts
import { serverRoute } from 'hono-preact';

const route = serverRoute('/board/:id');

export const serverSockets = {
  boardChat: route.socket<
    { text: string },
    { text: string },
    { boardId: string }
  >({
    // Route params are validated at the upgrade and passed to the factory.
    data: (_c, { id }) => ({ boardId: id }),
    message(socket, msg) {
      socket.send({ text: msg.text });
    },
  }),
};
// on the /board/:id page
import { useParams } from 'hono-preact';
import { serverSockets } from './server/boards/board-chat.server.js';

const { id } = useParams('/board/:id');
const { send } = serverSockets.boardChat.useSocket({ params: { id } });

Use deny(), not redirect(), to reject a realtime upgrade. A WebSocket handshake cannot follow an HTTP redirect, so a guard that throws redirect() on the socket path is treated as a deny (close 4403) and logs a warning. If you share a guard between an HTTP route and its socket, branch on the request so the HTTP path redirects and the socket path denies.

An explicit serverRoute(r).socket binding on a param-bearing route authorizes on the route's params: useSocket(ref, { params }) becomes required and typed from the route pattern. The client sends those params on the upgrade; the server validates them before the guard chain runs, denying the connection (close 4403) when a required param is missing. The validated params are readable by the guard as ctx.location.pathParams, and are the same values passed to the data factory's second argument, so an authorization check or the seed for socket.data can key off them directly.

A colocated socket, a .server.ts sibling of a route with no serverRoute binding, has no param wire. ctx.location.pathParams stays empty, and the connection is never denied for a missing param, even next to a route with :params. It never fails the boot for this either. When its mount route declares a param a guard could read and at least one guard tier is live (app-level use, the route's page-layer use, or the socket's own use), a console advisory names the socket and the params a guard would read as undefined. That advisory prints in production too, not just in dev: it is your only signal that the mismatch is live. Reach for an explicit serverRoute(r).socket binding whenever a socket needs to authorize on a route param. Rooms resolve their params differently, from the channel key rather than this params option; see Rooms.

A route param outside the supported :name class (one or more of [A-Za-z0-9_], with an optional ?/*/+), like a hyphenated /board/:board-id, gets special treatment. Its value never reaches ctx.location.pathParams for a realtime connection, even though preact-iso's runtime matcher binds it fine over plain HTTP, so the params wire can't see it. The colocated-socket advisory still can: it reasons over every param a guard could actually read off the route (preact-iso's own matcher, not this framework's narrower :param grammar), so a hyphenated segment still fires the advisory rather than staying silent. A colocated socket on such a route is not rejected; it just never sees that param, the same as any other route param a colocated socket doesn't wire up. An explicitly bound serverRoute(r).socket fails the boot outright, whether or not a guard is live: serverRoute('/board/:board-id').socket(...) throws at startup rather than run with an empty params object, so every /__loaders request, action POST, and /__sockets upgrade returns a 500 until you rename the segment to use only letters, digits, and underscores.

The params a socket sees, both the data factory's second argument and the guard's ctx.location.pathParams, are plain objects: read them any way you like. You never have to worry about a param name clashing with a built-in like constructor or toString, because a route can't declare one in the first place: serverRoute('/plugin/:constructor') throws at definition.

Cloudflare setup#

On Cloudflare Workers, defineSocket runs inside a per-connection Durable Object, so it needs the HONO_PREACT_REALTIME Durable Object binding and migration in wrangler.jsonc (the same binding rooms and live-loader pub/sub use). See Rooms → Cloudflare setup for the binding and migration. (This applies to defineSocket. The raw upgradeWebSocket path below is separate and needs no Durable Object binding: it upgrades in the worker via a WebSocketPair.)

Reconnect behavior#

useSocket reconnects automatically after unexpected drops. The default policy skips reconnect for close code 1000 (normal closure) and all 4000-4999 codes (application-defined). Code 4403 is issued by the framework when a socket's use guard rejects the upgrade, signaling that reconnecting would hit the same rejection.

Override the policy with shouldReconnect:

serverSockets.chat.useSocket({
  shouldReconnect: (ev) => ev.code !== 1000 && ev.code !== 4403,
  reconnect: {
    maxRetries: 10,
    minDelay: 500,
    maxDelay: 60_000,
    growth: 2,
  },
});

Disable the socket entirely with enabled: false; the hook will not connect until enabled becomes true.

Close codes#

CodeMeaning
1000Normal closure; no reconnect by default
4000-4999Application-defined; no reconnect by default
4403Guard rejected the upgrade; no reconnect by default

Raw WebSocket routes with upgradeWebSocket#

For routes that do not fit the typed socket pattern, upgradeWebSocket from hono-preact lets you register any WS route in api.ts using the same Hono handler API:

// src/api.ts
import { Hono } from 'hono';
import { upgradeWebSocket } from 'hono-preact';

const app = new Hono();

app.get(
  '/ws',
  upgradeWebSocket(() => ({
    onMessage(event, ws) {
      ws.send(`echo: ${event.data}`);
    },
    onClose() {
      // cleanup
    },
  }))
);

export default app;

Both adapters install a WebSocket upgrader, so the same upgradeWebSocket route works on Node and Cloudflare with identical onOpenonMessageonClose semantics. The two runtimes differ only in how the connection is minted: Node shares the framework's single connection (the one that powers serverSockets), while Cloudflare mints a fresh WebSocketPair per route and needs no Durable Object binding.

Node.js#

On Node the framework's adapter wires the WebSocket upgrade internally (the same connection that powers serverSockets), so you do not install @hono/node-ws, call createNodeWebSocket, or export injectWebSocket. Your api.ts only needs upgradeWebSocket from hono-preact:

// src/api.ts
import { Hono } from 'hono';
import { upgradeWebSocket } from 'hono-preact';

const app = new Hono();

app.get(
  '/ws',
  upgradeWebSocket(() => ({
    onMessage(event, ws) {
      ws.send(`echo: ${event.data}`);
    },
  }))
);

export default app;

A working end-to-end example lives at apps/example-node/ in the repo.

Cloudflare Workers#

The same api.ts route runs on Cloudflare with no adapter-specific code. The adapter upgrades each raw route in the worker via a WebSocketPair, so it needs no Durable Object binding (unlike defineSocket, which does). onOpen fires on Cloudflare just as it does on Node:

// src/api.ts
import { Hono } from 'hono';
import { upgradeWebSocket } from 'hono-preact';

const app = new Hono();

app.get(
  '/ws',
  upgradeWebSocket(() => ({
    onOpen(_e, ws) {
      ws.send('ready');
    },
    onMessage(event, ws) {
      ws.send(`echo: ${event.data}`);
    },
  }))
);

export default app;

Each raw route is its own WebSocketPair connection; for cross-connection fan-out use rooms, and for the typed duplex primitive use defineSocket (which runs in the Durable Object).

Avoiding reserved paths#

Register WebSocket routes on any non-colliding path. The build rejects catch-all and reserved-path route registrations in api.ts. See Composing Hono Middleware for the full rules.

API reference#

defineSocket<Incoming, Outgoing, Data>(handler)#

ParameterTypeDescription
Incomingtype paramMessage type the client sends (JSON-serializable).
Outgoingtype paramMessage type the server sends (JSON-serializable).
Datatype paramPer-connection data bag type. Default: undefined.

handler fields:

FieldTypeDescription
useReadonlyArray<Middleware>Guard chain run before the upgrade. A failing guard closes with code 4403.
data(c: Context, params: Params) => Data | Promise<Data>Optional. Edge factory run once at the upgrade with the live Context and the route's validated params ({} for a bare defineSocket, or the route's params for a serverRoute(r).socket binding); its result seeds socket.data. May be async. The only place a socket handler sees a Context (on Cloudflare the handler runs inside a Durable Object). On Cloudflare the data() result rides a request header to the Durable Object (6KB limit); the Node dev server warns when the factory result exceeds this budget.
open(socket) => void | (() => void) | Promise<...>Runs once per connection. Returning a function registers a teardown (Node only; on Cloudflare use close). Receives only the socket; its data is the data factory result.
message(socket, msg: Incoming) => void | Promise<void>Called for every incoming message.
close(socket, { code, reason }) => voidCalled when the connection closes.
error(socket, err) => voidCalled on a socket error.

socket fields:

FieldTypeDescription
send(msg)(msg: Outgoing) => voidSend a message to the client. JSON-encoded by the framework.
close(code?, reason?)methodClose the connection.
dataReadonly<Data>Per-connection data seeded by the data factory at connect time. Read-only; for Node-only mutable per-connection state, capture a closure variable in open().
rawunknownEscape hatch to the underlying runtime socket.

ref.useSocket(opts) / useSocket(ref, opts)#

opts is optional for a bare or param-less socket, and required for a serverRoute(r).socket binding with route params (to supply params; see below).

OptionTypeDefaultDescription
paramsroute paramsrequired for a param-bearing serverRoute(r).socket binding, absent otherwiseThe bound route's params, typed from the route pattern. Sent on the upgrade and validated server-side; a missing required param denies the connection (4403).
onMessage(msg: Serialize<Outgoing>) => voidCalled for every incoming message. Does not trigger a re-render.
onOpen() => voidCalled when the connection opens.
onClose(e: CloseEvent) => voidCalled when the connection closes.
shouldReconnect(e: CloseEvent) => booleanSee belowReturns true to reconnect.
reconnect.maxRetriesnumber5Maximum reconnect attempts.
reconnect.minDelaynumber250Minimum delay in ms before first retry.
reconnect.maxDelaynumber30000Backoff cap in ms.
reconnect.growthnumber2Exponential growth factor.
enabledbooleantrueWhen false, the socket does not connect.
lastMessagebooleanfalseWhen true, the latest message is stored in reactive state and returned as lastMessage.

Default shouldReconnect: false for codes 1000 and 4000-4999, true otherwise.

Returns:

FieldTypeDescription
send(msg: Incoming) => voidSend a message. Queued if not yet open.
statusSocketStatusReactive connection status.
close(code?, reason?) => voidClose the connection. Suppresses reconnect.
closeInfoSocketCloseInfo | undefinedCode, reason, and wasClean from the last close event.
lastMessageSerialize<Outgoing> | undefinedLast received message, when opts.lastMessage is true.

upgradeWebSocket(createEvents)#

Wraps Hono's upgradeWebSocket pattern. Works on both adapters: Node shares the framework connection, Cloudflare upgrades via a per-route WebSocketPair (no Durable Object). onOpen fires on both.

ParameterTypeDescription
createEvents(c: Context) => WSEvents | Promise<WSEvents>Factory that returns the Hono WSEvents handler.

Returns a MiddlewareHandler for use in app.get('/path', upgradeWebSocket(...)).

Type exports#

import type {
  SocketRef,
  SocketHandler,
  ServerSocket,
  SocketStatus,
  SocketCloseInfo,
  ReconnectOptions,
  UseSocketOptions,
  UseSocketResult,
  UseSocketArgs,
} from 'hono-preact';

UseSocketArgs<R> is the type of useSocket's options rest tuple ([opts?: UseSocketOptions<R>] for a param-less binding, [opts: UseSocketOptions<R>] for a param-bearing one): useful for typing a generic wrapper that forwards its own rest args to useSocket without re-deriving the required-vs-optional rule itself.

See also#