Hibernation
A room that nobody is using should not cost anything to keep. When a room goes quiet, irtio serializes its whole state into one blob, stops the worker running it, and lets the machine underneath go away. The next time anyone needs that room, the blob comes back, the room resumes at the tick it stopped on, and clients carry on.
This is not a save file you manage. It happens on its own, and for most rooms the only thing you
need to do about it is keep everything that matters in state rather than in a variable beside it.
What puts a room to sleep
Both room modes hibernate, but “idle” cannot mean the same thing in each. An event-mode room is supposed to sit still between inputs. A tick-mode room is supposed to run whether anyone sends anything or not.
| Mode | Sleeps after idleMs of |
|---|---|
'event' | no inbound frames, even with clients connected |
'tick' | no connected clients |
idleMs defaults to 30_000. Setting it to 0 opts out and keeps the room resident.
// irtio/room.ts
export default defineRoom(schema, {
mode: 'event',
idleMs: 60_000, // sleep after a minute of silence
}); An event-mode room can also ask to sleep immediately:
rpc: {
endMatch(state, _params, ctx) {
state.match.status = 'over';
ctx.room.sleep(); // event mode only
},
} room.sleep() in a tick-mode room does nothing and logs room.sleep() is event-mode only; ignored in tick mode. It is not an error, which makes it easy to miss when porting an event room to tick.
Beyond individual rooms, the machine your rooms run on goes idle too. When it does, every room still running on it is snapshotted and stopped together. From a room’s point of view that is the same hibernation, and waking from it takes longer because the machine has to come back first.
One containment rule worth knowing: if the snapshot cannot be written, the hibernation is abandoned and the room keeps running. State is never traded away for a sleep.
What survives
Everything in the snapshot comes back exactly as it was:
- Every entity collection and singleton, including field values and the owner of every instance.
- Insertion order. A collection iterates in the order its instances were added, on the server and in every client’s view, across a sleep and wake.
room.tick. The room resumes counting from where it stopped.- The
room.random()stream. A seeded room replays identically across a wake. - The physics world, if the room has one. Body positions, velocities and the entity-to-body mapping ride inside the same blob as the state, so the two can never come back out of step. A ball mid-flight resumes its arc. See Physics.
Two things survive without being in the blob at all:
- Durable alarms.
room.alarm(name, atMs)is held outside the room, so it fires on time whether the room is awake, asleep, or its machine is stopped. See Alarms. - Player storage.
room.kvwas never room state to begin with. It outlives the room entirely.
What does not survive
room.setTimeout and room.setInterval are cancelled. Every pending room timer is dropped
when the room goes to sleep and nothing re-arms it on wake. They exist for things that only matter
while the room is awake.
// Wrong: this never fires if the room sleeps first, which it will after idleMs.
rpc: {
startRound(state, _params, ctx) {
state.match.phase = 'asking';
ctx.room.setTimeout(15_000, () => {
state.match.phase = 'reveal';
});
},
} // Right: an alarm is a name plus a due time, and the handler lives in the room config, so
// there is no callback for a hibernation to lose.
export default defineRoom(schema, {
mode: 'event',
alarms: {
round(state, room) {
if (state.match.phase !== 'asking') return; // a late alarm must be a no-op
state.match.phase = 'reveal';
},
},
rpc: {
startRound(state, _params, ctx) {
state.match.phase = 'asking';
ctx.room.alarm('round', ctx.room.now + 15_000);
},
},
}); Use room.setTimeout for precision while the room is awake and an alarm for anything that has to
happen either way. Alarms covers the timing guarantees, which are
coarser than a timer’s.
Module-level variables are gone. The worker running your room is terminated on sleep and a
fresh one imports your bundle again on wake. Anything you kept outside state starts back at its
initial value.
// irtio/room.ts
let roundsPlayed = 0; // resets to 0 on every wake. Put this in a singleton instead. Pending promises are rejected. A room.call() waiting on a client, a room.save(), or a room.kv read still in flight when the room sleeps rejects with room is hibernating. There is
nothing left running to deliver the continuation to. An abandoned save leaves the room untouched.
Presence starts empty. room.clients is rebuilt from the sockets that are still attached when
the room wakes, not restored from the blob. That is what keeps it honest: a client whose
connection died while the room slept is not there at all.
What wakes it
- A client joining. The join waits for the wake and then proceeds normally.
- A frame from a client whose socket stayed open. It is queued and delivered once the room is up.
- A durable alarm coming due.
- If the machine was stopped as well, the control plane notices on its next sweep, places the room, and the wake follows from there. That sweep runs every ten seconds, which is where the seconds of lateness in the alarm guarantees come from.
Waking is fast but not free. A small room whose machine was still warm measured about 520 ms end to end on staging. A physics room whose machine had to be restored from a memory snapshot measured 1.4 to 1.7 seconds, mostly because that machine holds twice the memory. A cold start with nothing warm at all measured about 1.8 seconds. Design your join screen for the second case, not the first.
onSleep and onWake
Two optional handlers bracket the transition. Most rooms need neither.
// irtio/room.ts
export default defineRoom(schema, {
mode: 'event',
onCreate(state) {
// Runs once, on the room's first creation. It does NOT run again on a wake.
state.match.status = 'open';
},
onSleep(state, room) {
room.log(`sleeping at tick ${room.tick} with ${state.seats.size} seats`);
},
onWake(state, room) {
room.log(`woke at tick ${room.tick}`);
},
}); onSleep runs while the snapshot is being taken, so anything it writes is in the blob. onWake runs after the state is restored and before any client rejoins. onCreate runs on first creation
only, which is the distinction to hold on to: a woken room is not a new room.
What a client sees
A client whose socket stayed open receives a fresh join and a fresh snapshot. Its connection
status never changes, and room.onStatus does not fire. Under the hood the client replaces its
authoritative state wholesale, re-applies any owned write it had not yet flushed on top of the new
snapshot, reseeds its render buffer, and resets prediction. Server-side, onJoin runs again for
that client with ctx.reconnecting === true.
That last part is why the reconnecting guard matters:
// irtio/room.ts
onJoin(state, ctx) {
// Without this, every wake spawns a second record for every client already here.
if (ctx.reconnecting) return;
state.players.add(ctx.clientId, { x: 0, y: 0, name: ctx.name || 'anon' }, {
owner: ctx.clientId,
});
}, A client whose reconnect grace expired while the room slept is not brought back. Its onLeave(state, ctx, 'timeout') is delivered to the woken room before any queued frames, so the
room sees the departure in the right order rather than discovering a ghost.
A client joining fresh sees nothing unusual, just a slower join.
The rules that fall out
- State is the only durable thing. If a value has to be there after a wake, it is a field on a collection or a singleton. Module scope and closures are not storage.
- Guard
onJoinwithctx.reconnecting. This is the single most common hibernation mistake in room code, and it shows up as duplicate players after a room has been quiet. - Use an alarm for anything with a deadline. A timer is a room-awake convenience. An alarm is the durable one.
- Write alarm and RPC handlers to tolerate a gap. A room can be asleep for a day. Check the phase before acting on a late alarm, and do not assume the time between two ticks was small.
- Do not treat
room.tickas a clock. It resumes where it stopped, so tick counts measure simulation, not elapsed time. Useroom.nowfor wall-clock questions. - Watch the blob size on a physics room. The Rapier world dominates it. Fifty bodies plus some static geometry measured about 56 KB, of which roughly 55 KB was the world. A room that sleeps and wakes often moves that every time.
- Do not hold a promise across a possible sleep.
room.call()to a client that is thinking is exactly the case that gets rejected.
Testing it
The test harness runs your room in memory, on a fake clock, with the room’s state held throughout.
That makes it the right tool for the logic around a sleep: it records the request as t.host.slept and t.host.sleeps, so you can assert that your room asks to sleep exactly when
you meant it to.
// irtio/room.test.ts
import { testRoom } from '@irtio/testing';
import room from './room.js';
const t = await testRoom(room);
const host = await t.join({ role: 'host' });
await host.call.endMatch({});
await t.tick();
expect(t.host.slept).toBe(true); // the room asked to sleep The round trip itself, serialize and restore, is the server’s job rather than the room’s. To
exercise it, run the room against irtio dev and let it idle out. That is worth doing once for
any room that keeps a match alive between sessions.
Next steps
- Alarms for the durable timer that replaces
setTimeouthere. - Presence and lifecycle for joins, reconnects, and the grace window.
- Room reference for
idleMs,room.sleep(), and the handler list. - Limits for what a sleeping room costs and how long snapshots are kept.