The WebSocket connection stays alive, but you don't pay for Duration — Applying Hono + Cloudflare Durable Objects Hibernation API
When I first tried implementing real-time chat or collaboration tools on Cloudflare Workers, I had the exact same concern. "If a Durable Object stays in memory as long as connections are alive... doesn't that mean I'm paying even at 4 AM when nobody's chatting?" Yes. That's exactly what happens with the standard WebSocket API. Durable Objects (hereafter DO) incur Duration costs measured in GB-seconds while an instance lives in memory, and those costs accumulate even if a client is connected but saying nothing.
One important distinction to make upfront: Duration costs are a DO-specific billing item, not how standard Workers are charged. Workers are billed by request count and CPU time; DOs add on top of that the wall-clock time the instance stays alive. Conflating these two billing models is a common source of confusion, so it's worth separating them early.
WebSocket Hibernation API tackles this DO-side cost problem head-on. The core idea is simple: when there are no messages, the DO is evicted from memory (hibernates), yet the WebSocket connection itself remains intact on the client side. When a new message arrives, the DO automatically wakes up to handle it, then goes back to sleep when things go quiet again. Duration costs are incurred only during the moments when CPU and memory are actually in use.
This post covers the practical pattern of using Hono as an entry point while implementing the Hibernation API directly inside a DO. We'll also look at some tricky parts: the fact that Hono's official Cloudflare adapter doesn't yet abstract Hibernation directly, and the fact that in-memory state is lost on every wake-up.
Why This Combination Now
How Hono Quickly Established Itself in the Workers Ecosystem
As of 2026, Hono is a framework that has rapidly established itself in the Cloudflare Workers environment. The serverless full-stack pattern of Workers + Hono + D1 + R2 is actively shared in the community, and Hono's upgradeWebSocket() helper handles WebSocket upgrade requests cleanly.
However, implementing WebSockets with Hono requires one structural choice. Hono itself is a stateless router. For multiple clients to connect to the same "channel" and exchange messages, something needs to manage that channel's state. That something is a Durable Object.
DO Billing Model and Hibernation's Fit
DO instances are billed in proportion to the time they remain in an active state (Active duration). Conversely, while hibernated, this Duration charge does not apply. That's why the benefit of Hibernation grows larger for workloads with many idle connections. For exact rates and line items, refer to the official DO pricing documentation.
Liveblocks struggled with scaling WebSocket servers on AWS, then migrated to Cloudflare Durable Objects + Hibernation API. When there is no activity in a collaboration room, the DO transitions to a hibernated state, maintaining connections while reducing costs.
Architecture: Division of Roles Between Worker and Durable Object
The Hono Worker and Durable Object have clearly separated responsibilities.
- Hono Worker: The public entry point. It handles routing logic — URL parsing, auth checks, channel ID extraction — and forwards the request to the DO stub corresponding to that channel.
- Durable Object: Manages channel (room)-level state and the actual WebSocket connections. Hibernation is automatically enabled with a single
ctx.acceptWebSocket(server)call.
This two-layer structure may look complex at first, but the pattern where DOs hold isolated state per channel and the Worker handles routing in front of them is actually quite clear in practice.
Implementation: From wrangler Config to Hibernation Handlers
1. wrangler.toml Configuration
name = "my-realtime-app"
main = "src/index.ts"
# Use the latest recommended compatibility_date for your project.
# Check the official docs below for WebSocket-related flags and recommended dates.
# https://developers.cloudflare.com/workers/configuration/compatibility-flags/
compatibility_date = "2024-09-23"
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]For compatibility_date, check the official documentation for the recommended value at your actual project start date. Which flags govern WebSocket Close frame handling, which date they're tied to, and what default behaviors are activated at that point are all documented in the Compatibility Flags docs. new_sqlite_classes enables SQLite as the DO storage backend; SQLite-backed Durable Objects were released in beta in 2024.
2. Hono Worker Entry Point
// src/index.ts
import { Hono } from 'hono'
type Bindings = {
CHAT_ROOM: DurableObjectNamespace
}
const app = new Hono<{ Bindings: Bindings }>()
app.get('/ws/:channelId', async (c) => {
const channelId = c.req.param('channelId')
const upgradeHeader = c.req.header('Upgrade')
if (upgradeHeader !== 'websocket') {
return c.text('Only WebSocket connections are allowed', 426)
}
const id = c.env.CHAT_ROOM.idFromName(channelId)
const stub = c.env.CHAT_ROOM.get(id)
return stub.fetch(c.req.raw)
})
export default app
export { ChatRoom } from './chat-room'The notable point here is that Hono's upgradeWebSocket() helper is not used. The Hibernation API must be activated via ctx.acceptWebSocket() inside the DO class, and Hono's official Cloudflare adapter does not yet abstract this directly (GitHub Issue #4506). So the Worker's role here is simply to forward requests to the DO.
3. Applying Hibernation to a Durable Object
// src/chat-room.ts
import { DurableObject } from 'cloudflare:workers'
type MessageData = {
type: 'message' | 'join' | 'leave'
userId: string
content?: string
}
export class ChatRoom extends DurableObject {
async fetch(request: Request): Promise<Response> {
// Official Cloudflare idiom: tuple destructuring is type-safe.
const { 0: client, 1: server } = new WebSocketPair()
const url = new URL(request.url)
const userId = url.searchParams.get('userId') ?? 'anonymous'
this.ctx.acceptWebSocket(server)
// Socket metadata accessible after wake-up from hibernation
server.serializeAttachment({ userId })
const joinMessage: MessageData = { type: 'join', userId }
this.broadcast(server, JSON.stringify(joinMessage))
return new Response(null, { status: 101, webSocket: client })
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const attachment = ws.deserializeAttachment() as { userId: string }
const parsed: MessageData = JSON.parse(message as string)
const broadcast: MessageData = {
type: 'message',
userId: attachment.userId,
content: parsed.content,
}
this.broadcast(ws, JSON.stringify(broadcast))
}
// Official Cloudflare signature: must accept all four params including wasClean to match the type definition.
async webSocketClose(
ws: WebSocket,
code: number,
reason: string,
wasClean: boolean,
): Promise<void> {
const attachment = ws.deserializeAttachment() as { userId: string }
const leaveMessage: MessageData = { type: 'leave', userId: attachment.userId }
this.broadcast(ws, JSON.stringify(leaveMessage))
ws.close(code, reason)
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
console.error('WebSocket error:', error, 'wasClean?', false)
ws.close(1011, 'Internal error')
}
// Broadcasts to all sockets except the sender.
// If the client needs a local echo, update the UI optimistically right after send.
private broadcast(sender: WebSocket, message: string): void {
for (const ws of this.ctx.getWebSockets()) {
if (ws !== sender && ws.readyState === WebSocket.READY_STATE_OPEN) {
ws.send(message)
}
}
}
}serializeAttachment / deserializeAttachment are important features provided by the Hibernation API. By attaching a small piece of data to each WebSocket socket, the DO can still identify which socket belongs to whom after waking up from hibernation.
The broadcast helper intentionally excludes the sender (sender). In a chat UI, it feels natural for the client to render its own message immediately without waiting for a server response — but if you maintain this policy, you must implement local echo on the client side. If you want to broadcast identically to all participants, remove the ws !== sender condition.
Hibernation Lifecycle: How It Actually Works
From the client's perspective, the connection does not drop and reconnect. The process of the DO sleeping and waking is handled transparently at the Cloudflare infrastructure layer. This is why no reconnection logic is needed.
In-Memory State Loss: The Most Important Pitfall
Every significant advantage of Hibernation comes with an associated constraint. During hibernation, the DO's in-memory state is lost. Data stored in class fields will be gone after a wake-up.
For example, if you were accumulating a chat message history in an in-memory array, it will be gone on every wake-up. This kind of state must be persisted to DO Storage.
// Wrong pattern: relying solely on in-memory state
export class ChatRoom extends DurableObject {
private messages: string[] = [] // Lost after hibernation
async webSocketMessage(ws: WebSocket, message: string) {
this.messages.push(message) // Reset on every wake-up
}
}There are two approaches for persisting to DO Storage. Storing an array under a single key is the most intuitive for conceptual understanding, but it is dangerous in production due to DO Storage's single-value size limit (approximately 128 KiB). As messages grow, writes will eventually fail.
// Conceptual example: simple slice pattern to keep only the last N entries (not suitable for production)
export class ChatRoom extends DurableObject {
async webSocketMessage(ws: WebSocket, message: string) {
const history = (await this.ctx.storage.get<string[]>('history')) ?? []
history.push(message)
if (history.length > 100) history.splice(0, history.length - 100)
await this.ctx.storage.put('history', history)
this.broadcast(ws, message)
}
}In real services, a key-per-message structure distributed across multiple keys is safer. Using sortable keys allows paging recent history via range queries without hitting the single-value size limit.
// Production-oriented example: one key per message
export class ChatRoom extends DurableObject {
async webSocketMessage(ws: WebSocket, message: string) {
// Sortable key: ISO timestamp + sequence number
const key = `msg:${Date.now().toString().padStart(16, '0')}`
await this.ctx.storage.put(key, message)
this.broadcast(ws, message)
}
async recentHistory(limit = 50): Promise<string[]> {
const map = await this.ctx.storage.list<string>({
prefix: 'msg:',
reverse: true,
limit,
})
return Array.from(map.values()).reverse()
}
}If you're using a SQLite-backed DO, storing data in an indexed table via ctx.storage.sql is the more natural approach.
Tradeoffs: When Is Hibernation Effective, and When Is It Not
| Item | Hibernation API | Standard WebSocket API |
|---|---|---|
| Idle connection Duration cost | None | Billed for the duration connections are held |
| In-memory state | Lost on wake-up | Preserved |
| Client reconnection | Not required | Not required |
| Outbound WebSocket | Not supported | Supported |
| Activation complexity | Single acceptWebSocket call |
Standard approach as-is |
| Suitable cases | Low-frequency message channels, collaboration tools, chat | Cases with tens of messages per second |
The lower the message frequency, the more dramatic Hibernation's cost savings. Conversely, for channels with dozens of messages per second — like games — the DO ends up awake nearly all the time, reducing the benefit.
Common Pitfalls in Practice
- Unhandled Close frames: If you don't explicitly call
ws.close(code, reason)in thewebSocketClosehandler, abnormal closures with code 1006 are likely. The signature must also accept all four parameters —(ws, code, reason, wasClean)— to match the latest type definitions. - Confusing outbound WebSockets: The Hibernation API applies only to inbound connections made by clients to the server. Outbound WebSockets from a DO connecting to an external server are not subject to hibernation.
- Underestimating storage read costs: Using Storage instead of in-memory state incurs a read operation on every wake-up. Without careful read/write pattern design, other costs can increase.
- Concurrent connection limits: For the latest numbers on WebSocket concurrent connection limits by plan, check the official Cloudflare Workers limits documentation. For large-scale services, review this in advance.
Wrapping Up: What You Can Observe in wrangler dev Logs
If this architecture is new to you, understanding can feel fragmented — and the fastest way to close that gap is through logs. Spin up a single chat room locally with wrangler dev and add a console.log at the top of fetch, webSocketMessage, and webSocketClose in the DO class. Connect from two browser tabs, exchange a message, wait quietly for about 30 seconds, then send another message.
Three things to observe:
- When the second message arrives,
webSocketMessageis called butfetchis not. This is evidence that from the client's perspective, it is not a new connection. - Add a counter as a class field and increment it with
++on every message. If there was an idle period, you'll see the value reset to 0 after wake-up. The in-memory state loss stops being an abstract concept and becomes visible. - The list of sockets returned by
ctx.getWebSockets()remains the same before and after hibernation. This is what makes the statement "the connection stays alive, only the process sleeps" concrete.
Once you've confirmed these three things locally with your own eyes, the Hibernation lifecycle transforms from a concept in documentation into something tangible. From there, whether it's storage design, auth layers, or problems you'll encounter in production — everything starts to look much more specific.
References
- Cloudflare Durable Objects - Official WebSocket Best Practices
- Cloudflare Durable Objects Lifecycle Official Docs
- Cloudflare Durable Objects Pricing
- Cloudflare Workers Compatibility Flags Docs
- Cloudflare Workers Limits Docs
- SQLite in Durable Objects Announcement Blog (2024)
- Hono Official WebSocket Helper Docs
- Cloudflare Workers WebSocket Examples Official Docs
- Hono WebSocket Hibernation API Support Request Issue (GitHub)
- Liveblocks Cloudflare Case Study
- Cloudflare Workers New Pricing Announcement Blog