Durable alarms

room.setTimeout is a live-only timer. Hibernation cancels it, so a room that goes idle mid-countdown never runs the callback. That is fine for anything cosmetic and useless for anything the game depends on.

A durable alarm is a named timer that survives hibernation and a full tenant stop. It fires whether or not the room is awake and whether or not anyone is connected, and if the room’s tenant had been stopped for an hour, the alarm that came due during that hour fires as soon as the room starts again. Overdue alarms are delivered, not dropped.

Alarms are how you build a round that ends on its own, an auction that closes, a daily reset, or a turn timer in a game where players are not online at the same time.

Handlers live in the room config

There is no room.onAlarm(...). Alarm handlers go in an alarms map in the room config, next to tick and rpc:

// irtio/room.ts
export default defineRoom(schema, {
  alarms: {
    close(state, room) { /* … */ },
  },
});

The reason is the whole point of the feature. A callback you registered at runtime would be gone after a hibernation, so the room would have to re-register it in onCreate and in onWake, and forgetting either would fail minutes later as “the timer never fired”. The config is code, so it is always there, in every process the room ever runs in.

Arming a name with no entry in alarms does nothing and logs a warning naming the fix. A redeploy that removes a handler while an alarm for it is still armed logs when that alarm arrives, and the alarm is lost rather than crashing the room.

room.alarm(name, atMs) arms one. room.cancelAlarm(name) disarms it, and cancelling a name that is not armed is a no-op.

Arming replaces, it does not stack

Alarms are addressed by name, and arming a name that is already armed replaces its due time. One name is one timer, always.

That is deliberate, and two useful behaviours come out of it:

  • Opening round 3 cannot leave round 2’s timer armed behind it. You do not have to cancel first, and there is never a window where two are live.
  • Re-arming from inside the handler is how you build a repeating timer. There are no recurring alarms, so re-arm instead.

A worked example

An auction room. The lot closes on a durable alarm, and a bid in the final stretch pushes the close out, which is the clearest demonstration of re-arming there is.

The schema

// irtio/schema.ts
import { defineSchema, entity, singleton, enumOf, f64, str, u32 } from '@irtio/schema';
import { rpc } from './rpc.js';

export const schema = defineSchema(
  {
    bidders: entity({ name: str(24) }, { serverOwned: true }),

    lot: singleton(
      {
        phase: enumOf('open', 'closed'),
        item: str(48),
        highBid: u32,
        highBidder: str(32),   // clientId of the leader, or '' before the first bid
        // On room.now's clock, so the room can always work out what is left. f64 rather than
        // u32 because room.now keeps climbing for as long as the server is up.
        closesAt: f64,
      },
      { serverOwned: true },
    ),
  },
  {
    project: 'p_your_project_id',
    roles: ['bidder'] as const,
    rpc,
  },
);

The RPCs

// irtio/rpc.ts
import { client, server, u32 } from '@irtio/schema';

export const rpc = {
  bid: server({ params: { amount: u32 } }),

  // Server to client. Sent whenever the close time moves, so every page restarts its
  // countdown from the same instant instead of polling.
  closing: client({ params: { ms: u32 } }),
};

The room file

// irtio/room.ts
import type { State } from '@irtio/schema';
import type { Room } from '@irtio/server';
import { defineRoom } from '@irtio/server';

import { schema } from './schema.js';

type AuctionState = State<typeof schema>;
type AuctionRoom = Room<typeof schema>;

const CLOSE_ALARM = 'close';
const LOT_MS = 60_000;
const EXTEND_MS = 15_000;

export default defineRoom(schema, {
  mode: 'event',
  idleMs: 10_000,     // short, so the room really does hibernate between bids

  alarms: {
    [CLOSE_ALARM](state, room) {
      // A late alarm has to be a no-op. It fires at or after its due time, and by then the
      // lot may already be closed, so the guard comes first and the work comes second.
      if (state.lot.phase !== 'open') return;

      state.lot.phase = 'closed';
      room.log(`lot closed at ${state.lot.highBid} to ${state.lot.highBidder || 'nobody'}`);
    },
  },

  onCreate(state, room) {
    state.lot.phase = 'open';
    state.lot.item = 'one very good chair';
    state.lot.highBid = 0;
    state.lot.highBidder = '';
    armClose(state, room, LOT_MS);
  },

  onJoin(state, ctx) {
    if (ctx.reconnecting) return;
    state.bidders.add(ctx.clientId, { name: ctx.name || 'anon' });

    // A joiner needs the countdown too, and what it needs is the time left, not the original.
    const left = Math.max(0, state.lot.closesAt - ctx.room.now);
    void ctx.room.call(ctx.clientId).closing({ ms: left }).catch(() => {});
  },

  onLeave(state, ctx) {
    if (state.bidders.has(ctx.clientId)) state.bidders.remove(ctx.clientId);
  },

  rpc: {
    bid(state, { amount }, ctx) {
      if (state.lot.phase !== 'open') throw new Error('bid: the lot is closed');
      if (amount <= state.lot.highBid) throw new Error('bid: too low');

      state.lot.highBid = amount;
      state.lot.highBidder = ctx.clientId;

      // Anti-sniping. A bid inside the final stretch pushes the close out to EXTEND_MS from
      // now. Arming the same name again replaces the due time, so there is no cancel, no
      // bookkeeping, and never two close timers in flight.
      const remaining = state.lot.closesAt - ctx.room.now;
      if (remaining < EXTEND_MS) armClose(state, ctx.room, EXTEND_MS);
    },
  },
});

/** Arms (or re-arms) the close, records the deadline, and tells every page to restart its bar. */
function armClose(state: AuctionState, room: AuctionRoom, ms: number): void {
  state.lot.closesAt = room.now + ms;
  room.alarm(CLOSE_ALARM, state.lot.closesAt);
  room.broadcast.closing({ ms });
}

atMs is on room.now’s clock. Write room.now + 15_000 and nothing else. room.now is a monotonic server clock rather than a wall-clock timestamp, so it is the right thing to compute a deadline from and the wrong thing to show a player as a date. The host translates your due time to wall clock on the way out, because an alarm has to outlive the process that computed it.

closesAt lives in room state, so it hibernates with the room and is still right after a wake. That is what makes the onJoin catch-up correct: the room can always answer “how long is left” without having kept anything in memory. room.call(clientId).closing({ ms }) sends it to one client, and room.broadcast.closing({ ms }) sends it to everyone connected.

The client

// src/main.ts
import { joinRoom } from '@irtio/client';
import { schema } from '../irtio/schema.js';

let countdownUntil = 0;

const room = await joinRoom(schema, {
  name: 'you',
  rpc: {
    // Server to client. Restart the local countdown from this instant.
    closing({ ms }) {
      countdownUntil = performance.now() + ms;
    },
  },
});

const bar = document.querySelector('#countdown') as HTMLElement;
const status = document.querySelector('#status') as HTMLElement;

document.querySelector('#bid')!.addEventListener('click', () => {
  const amount = room.state.lot.highBid + 10;
  room.call.bid({ amount }).catch((err: unknown) => console.error('bid failed:', err));
});

function frame() {
  const lot = room.state.lot;
  const left = Math.max(0, countdownUntil - performance.now());

  bar.style.width = lot.phase === 'open' ? `${Math.min(100, left / 600)}%` : '0%';
  status.textContent =
    lot.phase === 'closed'
      ? `sold at ${lot.highBid}`
      : `${lot.item}: ${lot.highBid}, ${Math.ceil(left / 1000)}s left`;

  requestAnimationFrame(frame);
}
frame();

The countdown runs on the client’s own clock, seeded by the server. That keeps the bar smooth without polling, and the server stays the only thing that decides when the lot actually closes.

What an alarm guarantees

An alarm fires at or after its due time, never before. The guaranteed resolution is seconds, not milliseconds. How late depends on what the room was doing when the alarm came due:

Room state when dueTypical lateness
Livemilliseconds
Hibernated, tenant still runningmilliseconds. The supervisor wakes the room and delivers.
Tenant stoppedseconds. Roughly 10 to 12 on a cold start.

The last row is the one to design around. The control plane notices a due alarm on its sweep, which runs every 10 seconds, then places the tenant, which has to boot before the room can start and fire what is overdue.

So a 60-second lot can close at 60.2 seconds, or at 70 if the tenant had to be started first. That is fine for an auction, a quiz round, or a daily reset. It is not a shot clock. If you need sub-second precision while the room is awake, use room.setTimeout for the precision and an alarm for the durability, and let the alarm handler be the one that decides anything the game depends on.

Firing is its own event, between ticks, in the same scheduling class as an RPC. A tick-mode room never runs a tick that is halfway through an alarm.

An armed alarm does not keep the room alive by itself. If a room is idle by every other measure, having an alarm armed does not stop it from hibernating or its tenant from stopping — the alarm’s only job is to be there and fire the next time the room runs, however that comes about. See /docs/concepts/hibernation for what actually decides idleness.

What is not there

  • No recurring alarms. Re-arm from inside the handler.
  • No sub-second alarms. The resolution is seconds.
  • No alarm addressed to one client. An alarm runs room code. If it should reach one player, have the handler call them.
  • No listing of armed alarms. Keep whatever your game needs to know in room state, as closesAt does above.
  • No single arm longer than about 24.8 days. Longer waits are re-armed in steps automatically, but a design that needs months of latency wants a real external scheduler, not this.

Reach for this when

  • Something has to happen even if nobody is connected: a round ending, an auction closing, a turn expiring in an async game, a daily reset.
  • You are writing an event-mode room, where hibernation is normal operation rather than an edge case. See /docs/concepts/hibernation.
  • You want a timer that a redeploy or a stopped tenant cannot quietly swallow.

Do not reach for this when

  • The timing is cosmetic or frame-accurate: a countdown bar, an animation, a debounce. That is room.setTimeout, which costs nothing and is precise while the room is awake.
  • You need sub-second accuracy for something the game’s rules depend on.
  • You want a repeating heartbeat at a high rate. Alarms are for events, not for a tick loop. If you want a loop, run the room in tick mode.

Next steps