TL;DR

  • With the Hibernation API, a Durable Object that sits idle is removed from memory while its WebSocket connections stay connected. When the next message arrives, the constructor runs again on a fresh instance. In my local runs, a field counting messages went from 5 back to 1, an age counter from 412 ms to 4 ms, and a field holding a nickname from its value back to null.
  • State that survives: whatever is in storage, and whatever you saved with serializeAttachment(). Changing the object returned by deserializeAttachment() and not calling serializeAttachment() again saved nothing.
  • The attachment is capped at 16,384 bytes. A 20 KB value threw an error at the call, not later.
  • Answering an application-level "ping" with setWebSocketAutoResponse() did not wake the object (constructor count stayed at 1), and neither did three WebSocket protocol ping frames.
  • Hibernation is not automatic: a pending setInterval kept the object in memory for the whole 15 s test, and so did using the standard accept() API.
  • Everything ran under wrangler dev --local (local workerd). I did not test Cloudflare’s production runtime, billing, alarms, or the 70 to 140 s eviction.

One sentence, two readings

The lifecycle page of the Durable Objects documentation describes the hibernated state in one line: “The Durable Object is removed from memory. Hibernated WebSocket connections stay connected.” A few lines later comes a caution: “When hibernated, the in-memory state is discarded, so ensure you persist all important information in the Durable Object’s storage.” (Lifecycle of a Durable Object).

Read quickly, these two statements describe a fine contract: the connection is yours to keep, and the state is yours to save. But the difference between “this WebSocket is still connected” and “this object still remembers who is on the other end” is exactly where the bugs come from. I wanted to see each side of it, so I built the smallest object that can show it.

The documentation lists the conditions for hibernation: no setTimeout or setInterval callbacks scheduled; no unfinished I/O or waitUntil() promise and no open outbound connection; no use of the standard WebSocket API; no request or event still being processed. After 10 seconds without events and with all of those true, the object hibernates. If any is false, it stays idle in memory, and after 70 to 140 seconds of inactivity it is evicted entirely. The “10 seconds” is described as the current behaviour, decided by the runtime.

The smallest object that shows the difference

The Durable Object below keeps one value in each place state can live, so a single JSON reply shows which ones survive:

  • messagesSeenInMemory, nickInMemory, bornAt: plain class fields (memory only).
  • constructions: a counter in the object’s storage, incremented in the constructor. It counts how many times the object has been created.
  • the attachment on each socket: joinedAt and nick.
  • getWebSockets().length: the sockets the runtime still knows about.
import { DurableObject } from "cloudflare:workers";

export class Room extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.bornAt = Date.now();                       // in memory only
    this.nickInMemory = null;                       // in memory only
    this.messagesSeenInMemory = 0;                  // in memory only
    this.constructions = (ctx.storage.kv.get("constructions") ?? 0) + 1;   // in storage: survives
    ctx.storage.kv.put("constructions", this.constructions);
    console.log(`[room ${ctx.id.name}] constructor ran (#${this.constructions})`);
    // answer the application-level "ping" without running our code
    ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
  }

  async fetch(request) {
    const [client, server] = Object.values(new WebSocketPair());
    if (new URL(request.url).searchParams.has("std")) {                      // the standard API instead: server.accept()
      server.accept();
      server.addEventListener("message", () => server.send(JSON.stringify({ objectAgeMs: Date.now() - this.bornAt, constructions: this.constructions })));
      return new Response(null, { status: 101, webSocket: client });
    }
    this.ctx.acceptWebSocket(server);                                        // the hibernation API
    server.serializeAttachment({ joinedAt: Date.now(), nick: "anon" });      // per-connection state that survives
    if (new URL(request.url).searchParams.has("timer")) setInterval(() => {}, 1000);   // a pending timer
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws, message) {
    this.messagesSeenInMemory++;
    const att = ws.deserializeAttachment();
    if (message === "mutate") att.nick = "changed-but-not-saved";            // no serializeAttachment() call
    if (message === "save")   { att.nick = "changed-and-saved"; ws.serializeAttachment(att); }
    if (message === "inmem")  this.nickInMemory = "kept-in-a-field";
    if (message === "big") {
      try { ws.serializeAttachment({ blob: "x".repeat(20000) }); ws.send("big: accepted"); }
      catch (e) { ws.send(`big: ${e.name}: ${e.message}`); }
      return;
    }
    ws.send(JSON.stringify({
      messagesSeenInMemory: this.messagesSeenInMemory,      // resets after hibernation
      objectAgeMs: Date.now() - this.bornAt,                // resets after hibernation
      nickInMemory: this.nickInMemory,                      // resets after hibernation
      constructions: this.constructions,                    // from storage: counts re-creations
      nick: ws.deserializeAttachment().nick,                // survives, if it was saved
      joinedAt: att.joinedAt,                               // survives
      sockets: this.ctx.getWebSockets().length,             // the connections themselves survive
    }));
  }

  async webSocketClose(ws, code) { console.log(`[room ${this.ctx.id.name}] close ${code}`); }
}

export default {
  async fetch(request, env) {
    const name = new URL(request.url).searchParams.get("room") ?? "lab";
    return env.ROOM.getByName(name).fetch(request);
  },
};
{
  "name": "hib-lab",
  "main": "src/index.js",
  "compatibility_date": "2026-04-07",
  "durable_objects": { "bindings": [{ "name": "ROOM", "class_name": "Room" }] },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Room"] }]
}

The client asks a few questions, waits 15 seconds (more than the 10 s idle rule), and asks again. It reads how many times the constructor ran from the dev server’s log.

// usage: node client.mjs <idle-seconds> "<query>"   e.g.  node client.mjs 15 "?room=a"
import fs from "node:fs";
import WebSocket from "ws";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const idle = Number(process.argv[2] ?? 15), query = process.argv[3] ?? "?room=lab";
const room = new URLSearchParams(query).get("room");
const constructors = () => (fs.readFileSync("wrangler.log", "utf8").match(new RegExp(`\\[room ${room}\\] constructor`, "g")) || []).length;

const ws = new WebSocket(`ws://localhost:8787/${query}`);
const inbox = [];
ws.on("message", (m) => inbox.push(m.toString()));
await new Promise((r) => ws.on("open", r));
const ask = async (text) => { inbox.length = 0; ws.send(text); for (let i = 0; i < 50 && !inbox.length; i++) await sleep(100); return inbox[0]; };
const t0 = Date.now(); const at = () => `[${((Date.now() - t0) / 1000).toFixed(1).padStart(4)}s]`;

for (const m of ["hi", "mutate", "hi", "save", "inmem", "big", "ping"]) console.log(at(), m.padEnd(7), "->", await ask(m));
console.log(at(), `idle for ${idle} s; the socket stays open`);
await sleep(idle * 1000);
console.log(at(), "readyState", ws.readyState, "(1 = OPEN); constructor runs so far:", constructors());
console.log(at(), "ping    ->", await ask("ping"), "| constructor runs now:", constructors());
console.log(at(), "hi      ->", await ask("hi"), "| constructor runs now:", constructors());
console.log(at(), "big     ->", await ask("big"));
ws.close();

Setup. Wrangler 4.147.0 needed Node 22 on my machine (I used 22.23.3), so I installed wrangler and ws into a scratch directory and ran the dev server with its output going to wrangler.log:

mkdir do && cd do && npm init -y
npm i wrangler ws            # I used wrangler 4.147.0 on Node 22.23.3
# save src/index.js and wrangler.jsonc as shown above, and client.mjs next to package.json
npx wrangler dev --local --port 8787 > wrangler.log 2>&1 &
node client.mjs 15 "?room=a"

Use a new room name for each run, since the object’s storage keeps the constructions counter between runs.

What I saw

This is one run, trimmed (the lines are as printed, cut to the interesting fields):

[ 0.0s] hi      -> messagesSeenInMemory 1, objectAgeMs 9,   nickInMemory null,              constructions 1, nick "anon",              sockets 1
[ 0.1s] mutate  -> messagesSeenInMemory 2, ...
[ 0.2s] hi      -> messagesSeenInMemory 3, ...                                               nick "anon"       (mutate was not saved)
[ 0.3s] save    -> messagesSeenInMemory 4, ...                                               nick "changed-and-saved"
[ 0.4s] inmem   -> messagesSeenInMemory 5, objectAgeMs 412, nickInMemory "kept-in-a-field", constructions 1, nick "changed-and-saved", sockets 1
[ 0.5s] big     -> big: Error: A WebSocket 'attachment' cannot be larger than 16384 bytes.'attachment' was 20015 bytes.
[ 0.6s] ping    -> pong
[ 0.7s] idle for 15 s; the socket stays open
[15.7s] readyState 1 (1 = OPEN); constructor runs so far: 1
[15.7s] ping    -> pong | constructor runs now: 1
[15.8s] hi      -> messagesSeenInMemory 1, objectAgeMs 4,   nickInMemory null,              constructions 2, nick "changed-and-saved", sockets 1 | constructor runs now: 2

In a table, for the 15 s idle:

WhatBefore the idleAfterWhere it lived
The socket’s readyState on the client11the connection
getWebSockets().length11runtime
messagesSeenInMemory51class field, lost
objectAgeMs412 ms4 msclass field, lost
nickInMemory"kept-in-a-field"nullclass field, lost
constructions12storage, kept and shows the object was re-created
nick, saved with serializeAttachment()"changed-and-saved""changed-and-saved"attachment, kept
joinedAt, saved when the socket was acceptedsame valuesame valueattachment, kept
nick after a mutate message that never called serializeAttachment()read back as "anon" on the next messagenot looked at again (the later save overwrote it)the change existed only in the object returned by deserializeAttachment()
The "ping" message answered by auto-responsepongpong, constructor count still 1answered by the runtime

Four things in that table are worth saying out loud:

  1. The client noticed nothing. The socket stayed OPEN, and the ping got its pong. There was no reconnect and no event. The only trace of hibernation is the constructor count going up, which is a log line on the server.
  2. The auto-response did not wake the object. setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")) made the runtime answer without running our code. That is the cheap way to implement an application-level keep-alive with this API. The request and response are each limited to 2,048 characters in the documentation (DurableObjectState). I did not test longer ones.
  3. A mutation must be written back. deserializeAttachment() gave me a value; I changed it; nothing was saved. The documentation says the same: “Modifications to value after calling this method are not retained unless you call it again.”
  4. The attachment has a hard limit, and it fails fast. The 20 KB value threw A WebSocket 'attachment' cannot be larger than 16384 bytes at the call. The documentation’s advice for larger values is to put them in storage and keep the key as the attachment (WebSockets best practices).

Protocol pings versus application pings

A second small client sent three WebSocket protocol ping frames to another fresh room after the same idle time:

import fs from "node:fs";
import WebSocket from "ws";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const room = process.argv[2] ?? "d";   // use a room name you have not used before
const count = () => (fs.readFileSync("wrangler.log", "utf8").match(new RegExp(`\\[room ${room}\\] constructor`, "g")) || []).length;
const ws = new WebSocket(`ws://localhost:8787/?room=${room}`);
await new Promise((r) => ws.on("open", r));
await sleep(15000);                                   // longer than the 10 s idle rule
console.log("constructor runs after 15 s idle:", count());
let pongs = 0; ws.on("pong", () => pongs++);
for (let i = 0; i < 3; i++) { ws.ping(); await sleep(300); }
console.log("protocol pong frames received:", pongs, "| constructor runs now:", count());
ws.send("hi"); await sleep(500);
console.log("after one real message, constructor runs:", count());
ws.close();
constructor runs after 15 s idle: 1
protocol pong frames received: 3 | constructor runs now: 1
after one real message, constructor runs: 2

The frames were answered, the constructor count stayed at 1, and only a real message caused the second construction. So, in this local runtime, neither keep-alive style woke the object. The documentation says the same about protocol-level frames (“Ping/pong handling does not interrupt hibernation”, WebSockets best practices), so this lab agrees with it. I did not test it in production; if your design depends on it, test it where you deploy.

Two ways to keep the object awake (that you may not want)

Two more rooms, same 15 s idle:

VariantAfter the idle
A pending setInterval(() => {}, 1000) set when the socket was acceptedno hibernation: objectAgeMs 15824, messagesSeenInMemory 7, nickInMemory kept, constructor count stayed 1
The standard API (server.accept() and addEventListener) instead of acceptWebSocket()no hibernation: objectAgeMs 15843, constructor count stayed 1

This matches the conditions in the documentation. A forgotten timer or a second WebSocket style makes the object a permanent in-memory resident, and the documentation says an object that is idle in memory and not hibernateable is charged for duration (Lifecycle). I did not measure any cost. Also, as the documentation puts it, pending I/O and open outbound connections prevent eviction, and I did not test that.

Hibernation is not a deploy

Hibernation keeps the connections. A shutdown does not. The same lifecycle page says that on a shutdown (a new deployment, a runtime update, a hosting decision), “WebSocket requests are terminated automatically”, and that Durable Objects “may shut down at any time”, with no shutdown hooks. So clients still need reconnect logic, and the server still needs to write important state incrementally. I did not test a deployment.

Rules this gives me

  • The constructor must be cheap and must not assume a first-ever start. It runs again after every hibernation. Do not use it to announce “room created”. Read what you need from storage.
  • Per-socket state goes into the attachment; per-room state goes into storage. Anything in a class field is a cache, and a cache may be empty on any message.
  • Re-serialize after every change. Wrap it in one helper so nobody mutates the attachment and forgets.
  • Keep the attachment small, and store large things in storage under a key you keep as the attachment.
  • Use auto-response for application-level keep-alive so the object can sleep through them.
  • Do not set timers, and do not hold outbound connections, in an object that you want to hibernate. Use the platform’s scheduling features instead (I did not test alarms).

When hibernation fits

SituationChoice
Many mostly-idle connections (chat rooms, presence, notifications), small per-connection stateHibernation API
The object must run its own periodic work while sockets are connected (game ticks, polling an upstream)Timers keep it in memory. Decide that on purpose and accept the duration charges, or split the periodic work into a separate object.
A long-lived outbound WebSocket to another service held by this objectIt cannot hibernate while that connection is open.
Large per-connection stateStorage plus a key in the attachment

Bugs that only show after a quiet period

  • State in class fields. Symptom: counters reset, “who is in the room” is wrong or empty after quiet periods. Reproduced above. Fix: storage or attachment, and getWebSockets() as the source of truth for who is connected.
  • Mutating the deserialized attachment. Symptom: a nickname or cursor change that works, then disappears after a quiet period. Reproduced. Fix: call serializeAttachment() again.
  • Attachment larger than 16 KiB. Symptom: an exception at the call. Reproduced. Fix: storage plus key.
  • A leftover timer or the standard API. Symptom: the object never hibernates and the duration bill grows. Reproduced the “never hibernates” part only. Fix: remove them, or accept them.
  • Using the constructor as a “room created” event. Symptom: welcome messages repeated after idle periods. The constructor counter above shows why. Fix: put the one-time work behind a flag in storage.
  • Testing only with a busy room. Symptom: everything works in a demo and breaks after lunch. Fix: always include a test that sleeps longer than the idle rule.

Run it locally

  1. Create a scratch directory with Node 22, npm i wrangler ws, save the four files above.
  2. Start the dev server as shown, with its output going to wrangler.log.
  3. node client.mjs 15 "?room=a1". Success: the “after” line shows messagesSeenInMemory 1, nickInMemory null, constructions 2, and nick unchanged.
  4. node client.mjs 15 "?room=a2&timer=1". Success: the “after” line shows messagesSeenInMemory 7 and the constructor count still 1.
  5. node client.mjs 15 "?room=a3&std=1". Success: no hibernation.
  6. node frame_ping.mjs a4. Success: 3 pongs, count stays 1.
  7. Change the 15 to 5, and see whether the object hibernates. Predict first, then compare with the documented 10 seconds.

Local workerd versus production

Verified under wrangler dev --local (wrangler 4.147.0, local workerd, Node 22.23.3, compatibility date 2026-04-07, Linux 6.12): everything in the “What I saw”, “Protocol pings versus application pings” and “Two ways” sections, as printed, one run each, with fresh room names. The 16 KiB attachment cap, the 2,048-character auto-response limit, the lifecycle conditions, the 10 s and 70 to 140 s figures, and the shutdown wording were read in Cloudflare’s documentation while writing.

Not verified: Cloudflare’s production runtime (local workerd may behave differently from production; the documentation notes that local development did not hibernate at all before wrangler 3.13.2, so the version matters), billing, alarms, tags, the 70 to 140 s eviction, the maximum number of hibernatable sockets, shutdown and deployment behaviour, and anything above one connection per room. The 15 s idle is a lab choice made to exceed the documented 10 s. Local timing is not a promise about production.

Fields are a cache

Treat every class field in a hibernating Durable Object as a cache that is empty on the next message. The socket survives, the state you wrote down survives, and nothing else does.