Plain DurableObject Usage
📘 Doc-testing – Why do these examples look like tests?
This documentation uses testable code examples to ensure accuracy and reliability:
- Guaranteed accuracy: All examples are real, working code that runs against the actual package(s)
- Guaranteed latest comparisons: Further, our release script won't allow us to release a new version of Lumenize, without prompting us to update any doc-tested comparison package (e.g. Cap'n Web)
- Always up-to-date: When a package changes, the tests fail and the docs must be updated
- Copy-paste confidence: What you see is what works - no outdated or broken examples
- Real-world patterns: Tests show complete, runnable scenarios, not just snippets
Ignore the test boilerplate (it(), describe(), etc.) - focus on the code inside.
The @cloudflare/actors alarms package solves a key limitation: Cloudflare only allows one native alarm per Durable Object instance. This package uses SQL storage to manage multiple scheduled tasks and ensures the single native alarm always fires for the next scheduled task.
This guide shows how to use Alarms without extending Actor - just a plain
DurableObject with manual Storage and Alarms setup.
Imports​
import { it, expect, vi } from 'vitest';
import { createTestingClient, type RpcAccessible } from '@lumenize/testing';
import { AlarmDO } from '../src';
Version​
This test asserts the installed version and our release script warns if we aren't using the latest version published to npm, so this living documentation should always be up to date.
import actorsPackage from '../node_modules/@cloudflare/actors/package.json';
it('detects package version', () => {
expect(actorsPackage.version).toBe('0.0.1-beta.6');
});
Installation​
npm install @cloudflare/actors
Setup Without Actor Base Class​
To use the Alarms package with a plain DurableObject, your class must:
- Manually create
Storagewrapper - Import from@cloudflare/actors/storage - Manually create
Alarmsinstance - Passctxandthis - Implement the
alarm()method - This delegates to the Alarms instance
Here's the complete Durable Object and Worker:
import { Storage } from "@cloudflare/actors/storage";
import { Alarms, type Schedule } from "@cloudflare/actors/alarms";
import { DurableObject } from "cloudflare:workers";
export class AlarmDO extends DurableObject<Env> {
storage: Storage;
alarms: Alarms<this>;
executedAlarms: string[] = [];
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.storage = new Storage(ctx.storage);
this.alarms = new Alarms(ctx, this);
}
// Required boilerplate: delegate to Alarms instance
async alarm() {
await this.alarms.alarm();
}
// Callback for alarms - gets called when an alarm fires
async handleAlarm(payload: any, schedule: Schedule) {
const message = `Alarm ${schedule.id} fired: ${JSON.stringify(payload)}`;
this.executedAlarms.push(message);
}
// Method to get executed alarms (for testing)
getExecutedAlarms(): string[] {
return this.executedAlarms;
}
// Method to clear executed alarms (for testing)
clearExecutedAlarms(): void {
this.executedAlarms = [];
}
}
// No default export needed - the test harness handles everything
The alarm() method is required boilerplate - Cloudflare's Durable Object
API requires you to implement this handler method. The Alarms class can't
automatically inject itself into that lifecycle hook, so you must explicitly
delegate to it.
Note: Unlike extending Actor (which auto-creates this.alarms), you must
manually instantiate both Storage and Alarms in your constructor.
Scheduling Alarms​
The Alarms package supports three types of schedules:
- Date-based: Execute at a specific time
- Delay-based: Execute after N seconds
- Cron-based: Recurring execution using cron expressions
it('schedules multiple alarms with different types', async () => {
// createTestingClient provides direct RPC access to the DO
await using client = createTestingClient<RpcAccessible<InstanceType<typeof AlarmDO>>>(
'ALARM_DO',
'multi-types'
);
// Clear any previous test data
await client.clearExecutedAlarms();
// 1. Schedule with a Date (execute at specific time)
const futureDate = new Date(Date.now() + 500); // 500ms from now
const dateSchedule = await client.alarms.schedule(
futureDate,
'handleAlarm',
{ type: 'date', message: 'Executed at specific time' }
);
expect(dateSchedule.type).toBe('scheduled');
expect(dateSchedule.callback).toBe('handleAlarm');
// 2. Schedule with delay in seconds
const delaySchedule = await client.alarms.schedule(
1, // 1 second
'handleAlarm',
{ type: 'delay', message: 'Executed after delay' }
);
expect(delaySchedule.type).toBe('delayed');
// 3. Schedule with cron expression (every minute)
// Note: We won't wait for cron to fire - it would take 60 seconds
// Cron syntax reference: https://crontab.guru
const cronSchedule = await client.alarms.schedule(
'* * * * *',
'handleAlarm',
{ type: 'cron', message: 'Recurring task' }
);
expect(cronSchedule.type).toBe('cron');
// With @lumenize/testing, alarms fire automatically! Just wait for them.
await vi.waitFor(async () => {
const executed = await client.getExecutedAlarms();
expect(executed.length).toBeGreaterThanOrEqual(2);
});
// Verify both alarms executed with correct payloads
const executed = await client.getExecutedAlarms();
expect(executed.some((msg: string) => msg.includes('date'))).toBe(true);
expect(executed.some((msg: string) => msg.includes('delay'))).toBe(true);
});
Managing Scheduled Alarms​
You can query and cancel scheduled alarms:
it('queries and cancels scheduled alarms', async () => {
// createTestingClient provides direct RPC access to the DO
await using client = createTestingClient<RpcAccessible<InstanceType<typeof AlarmDO>>>(
'ALARM_DO',
'manage'
);
// Schedule several alarms
const schedule1 = await client.alarms.schedule(
10, // 10 seconds
'handleAlarm',
{ task: 'task-1' }
);
const schedule2 = await client.alarms.schedule(
20, // 20 seconds
'handleAlarm',
{ task: 'task-2' }
);
// Get all scheduled alarms
const allSchedules = await client.alarms.getSchedules();
expect(allSchedules.length).toBeGreaterThanOrEqual(2);
// Get a specific schedule by ID
const retrieved = await client.alarms.getSchedule(schedule1.id);
expect(retrieved?.payload).toEqual({ task: 'task-1' });
// Cancel a schedule
const cancelled = await client.alarms.cancelSchedule(schedule2.id);
expect(cancelled).toBe(true);
// Verify it's gone
const afterCancel = await client.alarms.getSchedules();
expect(afterCancel.some((s: any) => s.id === schedule2.id)).toBe(false);
});
wrangler.jsonc​
{
"name": "actors-alarms-plain-usage",
"main": "test-harness.ts",
"compatibility_date": "2025-09-12",
"durable_objects": {
"bindings": [
{
"name": "ALARM_DO",
"class_name": "AlarmDO"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["AlarmDO"]
}
]
}
Try it out​
To run these tests:
vitest --run
For coverage reports:
vitest --run --coverage