Durable alarms

A durable alarm is a named timer that survives hibernation and a full server stop. It fires whether or not the room is awake and whether or not anyone is connected. Overdue alarms are delivered, not dropped: an alarm that came due while nothing was running fires as soon as the room starts again.

Use an alarm to 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.

room.setTimeout callbacks are cancelled by hibernation. The room wakes at the earliest pending one-shot deadline; store a wall-clock deadline in state and handle it in onWake. A room.setInterval gets no wake — a repeating cadence is exactly what an alarm is for. Use a durable alarm to retain the handler name and schedule across hibernation and server stops; a handler that re-arms its own alarm is a cadence that keeps running while the room sleeps.

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) { /* … */ },
  },
});

A callback you registered at runtime would be gone after a hibernation, and the room would have to re-register it in onCreate and in onWake. The config is code, so the handler is 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. Two useful behaviours come out of that:

  • 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
        // Wall clock (Date.now()), so it means the same instant after a wake. f64 rather than
        // u32 because epoch milliseconds do not fit in 32 bits.
        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 - Date.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') ctx.deny('bid: the lot is closed');
      if (amount <= state.lot.highBid) ctx.deny('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 - Date.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 = Date.now() + ms;   // stored: wall clock
  room.alarm(CLOSE_ALARM, room.now + ms);  // armed: room.now's clock
  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 due time from and the wrong thing to show a player as a date. irtio translates your due time to wall clock on the way out, because an alarm has to outlive the process that computed it.

closesAt is a different case, because it is stored. It lives in room state and hibernates with the room, but room.now starts again in the worker a wake spawns, so a room.now value saved before a hibernation means nothing after it. Store it as wall clock (Date.now() + ms) and subtract Date.now() to get the time left. 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 was running when the alarm came due:

State when dueTypical lateness
Room awakemilliseconds
Room asleep, your project’s server runningmilliseconds. The room wakes and the alarm fires.
Your project’s server stoppedseconds. Roughly 10 to 12 on a cold start.

The last row is the one to design around. irtio checks for due alarms every 10 seconds, then starts your server, 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 server 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. If a room is idle by every other measure, an armed alarm does not stop it hibernating or its server stopping. The alarm’s only job is to be there and fire the next time the room runs. See Hibernation for what decides idleness.

Scheduling patterns

  • Re-arm an alarm from its handler for recurring work.
  • Keep scheduled times in room state, as wall clock (Date.now()), when the game needs to display them.
  • Send a message from the handler to notify an individual player.
  • Use room.setTimeout for animations and short timers while a room is awake. Durable alarms have second-level resolution.

Long waits are re-armed internally in steps of about 24.8 days.