Implementing WebSockets with Deno and Hono
#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.tsbehind 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.