Implementing WebSockets with Deno and Hono

Team 8 min read

#deno

#hono

#websocket

#realtime

Implementing WebSockets with Deno and Hono

This guide shows how to implement WebSockets using Deno and Hono, from a minimal echo server to production-ready patterns like broadcasts, rooms, authentication, heartbeats, and deployment tips.

WebSockets are ideal for low-latency, bidirectional communication such as chats, multiplayer updates, dashboards, and collaborative apps. If you only need server-to-client streaming, consider Server-Sent Events (SSE) instead. For two-way messaging, WebSockets are a great fit.

Prerequisites

  • Deno 1.40+ (recommend latest)
  • Hono 4.x
  • Basic familiarity with TypeScript

Project setup

Create a new project:

  • deno.json
{
  "tasks": {
    "dev": "deno run -A --watch main.ts",
    "start": "deno run -A main.ts"
  },
  "imports": {
    "hono": "https://deno.land/x/hono@v4.5.7/mod.ts"
  }
}
  • main.ts
import { Hono } from "hono"

const app = new Hono()

app.get("/", (c) => c.text("OK"))

Deno.serve(app.fetch)

Run deno task dev and open http://localhost:8000.

Minimal WebSocket echo with Hono + Deno.upgradeWebSocket

You can upgrade the request inside a Hono route by using Deno.upgradeWebSocket. This is runtime-native and works well in Deno and Deno Deploy.

import { Hono } from "hono"

const app = new Hono()

app.get("/ws", (c) => {
  // Optional: do auth/origin checks before upgrading (shown later)
  const { socket, response } = Deno.upgradeWebSocket(c.req.raw)

  socket.onopen = () => {
    console.log("WS open")
    socket.send("hello from server")
  }

  socket.onmessage = (event) => {
    // Echo
    socket.send(`echo: ${event.data}`)
  }

  socket.onclose = () => {
    console.log("WS closed")
  }

  socket.onerror = (e) => {
    console.error("WS error", e)
  }

  return response
})

Deno.serve(app.fetch)

Client (browser):

<script type="module">
  const ws = new WebSocket("ws://localhost:8000/ws")
  ws.onopen = () => console.log("connected")
  ws.onmessage = (e) => console.log("message:", e.data)
  ws.onclose = () => console.log("closed")
</script>

Broadcast to all clients

Maintain a set of sockets to broadcast messages.

import { Hono } from "hono"

const app = new Hono()
const clients = new Set<WebSocket>()

function safeSend(ws: WebSocket, data: string | ArrayBufferLike | Blob) {
  if (ws.readyState === WebSocket.OPEN) {
    try {
      ws.send(data)
    } catch {
      // Ignore send errors; cleanup on close
    }
  }
}

app.get("/ws", (c) => {
  const { socket, response } = Deno.upgradeWebSocket(c.req.raw)

  socket.onopen = () => {
    clients.add(socket)
    console.log("client connected, total:", clients.size)
    safeSend(socket, JSON.stringify({ type: "welcome" }))
  }

  socket.onmessage = (event) => {
    // Fan out the received message to everyone
    for (const client of clients) {
      if (client !== socket) {
        safeSend(client, event.data)
      }
    }
  }

  const cleanup = () => {
    clients.delete(socket)
    console.log("client disconnected, total:", clients.size)
  }

  socket.onclose = cleanup
  socket.onerror = cleanup

  return response
})

Deno.serve(app.fetch)

Rooms (channels) and a simple message protocol

Define a small JSON protocol and track membership per socket.

import { Hono } from "hono"

type Inbound =
  | { type: "join"; room: string }
  | { type: "leave"; room: string }
  | { type: "chat"; room: string; text: string }
  | { type: "pong"; ts?: number }

type Outbound =
  | { type: "system"; text: string }
  | { type: "chat"; room: string; text: string; user?: string }
  | { type: "ping"; ts: number }

const app = new Hono()

// room name -> members
const rooms = new Map<string, Set<WebSocket>>()
// socket -> joined rooms
const membership = new Map<WebSocket, Set<string>>()

function joinRoom(room: string, ws: WebSocket) {
  const set = rooms.get(room) ?? new Set<WebSocket>()
  set.add(ws)
  rooms.set(room, set)

  const mine = membership.get(ws) ?? new Set<string>()
  mine.add(room)
  membership.set(ws, mine)
}

function leaveRoom(room: string, ws: WebSocket) {
  const set = rooms.get(room)
  if (!set) return
  set.delete(ws)
  if (set.size === 0) rooms.delete(room)

  const mine = membership.get(ws)
  if (mine) {
    mine.delete(room)
    if (mine.size === 0) membership.delete(ws)
  }
}

function broadcastTo(room: string, payload: Outbound, except?: WebSocket) {
  const set = rooms.get(room)
  if (!set) return
  const data = JSON.stringify(payload)
  for (const ws of set) {
    if (ws !== except && ws.readyState === WebSocket.OPEN) {
      try {
        ws.send(data)
      } catch {}
    }
  }
}

function parseInbound(data: unknown): Inbound | null {
  try {
    const obj = typeof data === "string" ? JSON.parse(data) : data
    if (!obj || typeof obj !== "object") return null
    const t = (obj as any).type
    if (t === "join" && typeof (obj as any).room === "string") return obj as Inbound
    if (t === "leave" && typeof (obj as any).room === "string") return obj as Inbound
    if (t === "chat" && typeof (obj as any).room === "string" && typeof (obj as any).text === "string") return obj as Inbound
    if (t === "pong") return { type: "pong", ts: (obj as any).ts }
    return null
  } catch {
    return null
  }
}

const HEARTBEAT_INTERVAL = 25_000
const HEARTBEAT_GRACE = 10_000
const lastPong = new WeakMap<WebSocket, number>()

app.get("/ws", (c) => {
  const { socket, response } = Deno.upgradeWebSocket(c.req.raw)

  let heartbeat: number | undefined

  socket.onopen = () => {
    // initial state
    lastPong.set(socket, Date.now())

    // server-initiated heartbeat
    heartbeat = setInterval(() => {
      const now = Date.now()
      const last = lastPong.get(socket) ?? 0
      if (now - last > HEARTBEAT_INTERVAL + HEARTBEAT_GRACE) {
        try {
          socket.close(4000, "heartbeat timeout")
        } catch {}
        return
      }
      if (socket.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify({ type: "ping", ts: now } satisfies Outbound))
      }
    }, HEARTBEAT_INTERVAL) as unknown as number
  }

  socket.onmessage = (event) => {
    const msg = parseInbound(event.data)
    if (!msg) return

    switch (msg.type) {
      case "join":
        joinRoom(msg.room, socket)
        broadcastTo(msg.room, { type: "system", text: `joined ${msg.room}` })
        break
      case "leave":
        leaveRoom(msg.room, socket)
        broadcastTo(msg.room, { type: "system", text: `left ${msg.room}` })
        break
      case "chat":
        broadcastTo(msg.room, { type: "chat", room: msg.room, text: msg.text }, /*except*/ undefined)
        break
      case "pong":
        lastPong.set(socket, Date.now())
        break
    }
  }

  const cleanup = () => {
    clearInterval(heartbeat)
    const mine = membership.get(socket)
    if (mine) {
      for (const room of mine) leaveRoom(room, socket)
    }
    membership.delete(socket)
  }

  socket.onclose = cleanup
  socket.onerror = cleanup

  return response
})

Deno.serve(app.fetch)

Client with auto-reconnect and heartbeat:

function connect() {
  const ws = new WebSocket(location.origin.replace(/^http/, "ws") + "/ws")
  let pings = new Map()

  ws.onopen = () => {
    console.log("connected")
    ws.send(JSON.stringify({ type: "join", room: "general" }))
  }

  ws.onmessage = (e) => {
    const msg = JSON.parse(e.data)
    if (msg.type === "ping") {
      ws.send(JSON.stringify({ type: "pong", ts: msg.ts }))
    } else {
      console.log(msg)
    }
  }

  ws.onclose = () => {
    console.log("disconnected, retrying...")
    setTimeout(connect, 1000 + Math.random() * 2000)
  }
}

connect()

Authentication and authorization

Perform authentication before calling Deno.upgradeWebSocket. If unauthorized, return a normal HTTP response.

  • Token via query param or header
  • Validate Origin to mitigate cross-site abuse
  • Optionally sign a short-lived JWT

Example with a bearer token and Origin check:

app.get("/ws", (c) => {
  const origin = c.req.header("origin") ?? ""
  const allowed = ["https://yourapp.com", "http://localhost:5173"]
  if (!allowed.includes(origin)) {
    return c.text("Forbidden", 403)
  }

  const auth = c.req.header("authorization") ?? ""
  const token = auth.startsWith("Bearer ") ? auth.slice(7) : null
  if (token !== Deno.env.get("WS_TOKEN")) {
    return c.text("Unauthorized", 401)
  }

  const { socket, response } = Deno.upgradeWebSocket(c.req.raw)

  // ... attach handlers

  return response
})

If you need per-room authorization, verify permissions on join/leave messages as well.

Binary messages

You can send ArrayBuffer/TypedArray for efficient binary payloads:

socket.onmessage = (e) => {
  if (typeof e.data !== "string") {
    // e.data might be a Blob or ArrayBuffer depending on client
  }
}

On the client, set ws.binaryType = "arraybuffer" to receive ArrayBuffers.

Running behind a proxy or load balancer

  • Use wss in production.
  • Ensure the proxy preserves Upgrade and Connection headers.

Nginx example:

location /ws {
  proxy_pass http://127.0.0.1:8000;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_set_header Host $host;
}

Kubernetes Ingress: enable websocket upgrades (varies by controller). For multi-instance deployments, either use sticky sessions or a shared pub/sub to fan out messages across instances.

Scaling and state

A single process can hold connections in memory, but at scale:

  • Use Redis/NATS/Upstash for pub/sub between instances.
  • Keep only connection metadata in memory; publish messages through the broker so all instances deliver to members they host.
  • Avoid strict stickiness to allow horizontal scaling, unless required by your design.

Deployment notes

  • Deno Deploy supports WebSockets with Deno.serve and Deno.upgradeWebSocket.
  • If you need durable shared state or cross-instance broadcast, use an external broker. Process memory in serverless isolates is ephemeral and not shared between regions/instances.
  • On VMs/containers, just run deno run -A main.ts behind your proxy/TLS terminator.

Testing

Basic connection test with Deno’s test runner:

// test_ws.ts
Deno.test("ws echo", async () => {
  const base = "http://localhost:8000"
  const ws = new WebSocket(base.replace(/^http/, "ws") + "/ws")
  const next = () =>
    new Promise<MessageEvent>((resolve) => (ws.onmessage = resolve))

  await new Promise<void>((r) => (ws.onopen = () => r()))
  ws.send("hello")
  const msg = await next()
  if (!String(msg.data).includes("hello")) {
    throw new Error("did not echo")
  }
  ws.close()
})

Run the server in another terminal with deno task dev while tests run, or start/stop the server programmatically inside the test with an ephemeral port.

Troubleshooting

  • Connection closes immediately: check that your proxy forwards Upgrade/Connection headers.
  • Mixed content blocked: use wss when your page is served over https.
  • Origin mismatch: validate the Origin header and add your local/dev URLs during development.
  • Unexpected timeouts: implement heartbeats and reconnect logic; some environments terminate idle connections.

When to use SSE instead

If you only need server-to-client notifications and can tolerate one-way streaming with auto-reconnect, SSE may be simpler and more proxy-friendly. Choose WebSockets for interactive or high-frequency bidirectional messaging.

Summary

You’ve seen how to:

  • Upgrade to WebSockets inside Hono routes using Deno.upgradeWebSocket
  • Broadcast messages and manage rooms
  • Add authentication and origin checks
  • Implement heartbeats and client reconnection
  • Prepare for production proxies and scaling with a broker

This foundation lets you build real-time features on Deno and Hono with minimal overhead and clear, testable code.