# How Durable Object Alarms Work in the celld Runtime: Complete Technical Guide

> Explore how Durable Object alarms in the celld runtime ensure at-least-once callback delivery using in-memory buckets and SQLite persistence, even through crashes.

- Repository: [Deno/celld](https://github.com/denoland/celld)
- Tags: deep-dive
- Published: 2026-08-15

---

**Durable Object alarms in the celld runtime use a hybrid in-memory bucket and SQLite persistence layer to guarantee at-least-once delivery of scheduled callbacks, even across crashes or node migrations.**

Durable Object (DO) alarms enable serverless applications to schedule future work within a DO instance. In the **denoland/celld** runtime, this mechanism combines Rust-based in-memory state management with SQLite durability to ensure reliable alarm delivery. This article breaks down the complete alarm lifecycle, from JavaScript API calls through the underlying persistence layer.

## Alarm Storage Architecture

The alarm state resides in two locations: an in-memory bucket for fast scanning and SQLite for crash recovery.

### In-Memory State: The Cell and AlarmState

Each DO instance is represented by a `Cell` struct defined in **[`crates/logic/lib.rs`](https://github.com/denoland/celld/blob/main/crates/logic/lib.rs)**. The cell tracks alarm state through an optional field:

```rust
alarm: Option<AlarmState>

```

The `AlarmState` enum has two variants:
- **`Armed { op, next_alarm_ms }`** — alarm scheduled for future execution
- **`Firing`** — alarm currently executing

### SQLite Persistence Layer

Every cell maintains a private SQLite database for durability. The alarm timestamp is stored in the `alarm` column of the `celld_cell` table, with schema and helper functions implemented in **[`crates/logic/sqlite.rs`](https://github.com/denoland/celld/blob/main/crates/logic/sqlite.rs)**. This ensures alarms survive:
- Process crashes
- Node restarts
- Cell migration between nodes

When a cell is evicted from memory, the `Alarm::covered` method (see next section) verifies that pending alarms have matching bucket entries before allowing eviction.

## Arming an Alarm: The Alarm::arm Method

The core logic for scheduling alarms lives in **[`crates/logic/alarm.rs`](https://github.com/denoland/celld/blob/main/crates/logic/alarm.rs)**. The `Alarm::arm` method signature is:

```rust
pub fn arm(&mut self, cell: &str, next_alarm_ms: Ms) -> Result<()>

```

This method performs three critical operations:

1. **Creates a bucket entry** with key `entry_key(next_alarm_ms, cell)` — the bucket is a global structure that maps alarm times to cells
2. **Updates the cell's `AlarmState`** to `Armed` with the operation ID and target time
3. **Reschedules the global alarm timer** if this alarm is earlier than any existing one

The bucket entry key combines timestamp and cell ID, ensuring distinct deadlines for the same cell occupy separate entries.

### Example: Low-Level Rust Alarm Arming

```rust
use celld::logic::{Alarm, Ms};

fn schedule_periodic_task(cell_id: &str, delay_ms: i64) {
    let mut alarm = Alarm::new();
    let now = chrono::Utc::now().timestamp_millis();
    // arm() creates bucket entry + persists to SQLite
    alarm.arm(cell_id, now + delay_ms).expect("alarm arm failed");
}

```

This corresponds to the implementation in **[`crates/logic/alarm.rs`](https://github.com/denoland/celld/blob/main/crates/logic/alarm.rs)** (lines 127–144).

## The Global Alarm Scanner

Celld runs a single background task that discovers and fires due alarms. This scanner is implemented in **[`crates/logic/lib.rs`](https://github.com/denoland/celld/blob/main/crates/logic/lib.rs)** as `cell_alarm_timer`.

### Scanner Operation

The timer task executes **once per minute** and:

- Scans the bucket for the smallest `next_alarm_ms` value
- Pulls the earliest alarm entry
- Resolves the target cell
- Injects a `Firing` operation into that cell's phase

The scanner uses wall-clock milliseconds (`Ms`) for comparison against `now_ms` from the monotonic clock. Clock skew is naturally reconciled on the next scan iteration.

### Timer Rescheduling

When a new alarm is armed earlier than existing ones, `schedule_alarm_timer` (also in **[`crates/logic/lib.rs`](https://github.com/denoland/celld/blob/main/crates/logic/lib.rs)**) recalculates the next wake time. This avoids busy-waiting and minimizes latency for imminent alarms.

## Firing and Completing Alarms

When the scanner identifies a due alarm, the runtime transitions through distinct phases:

### Phase 1: Injection

The scanner sets `cell.alarm = Some(AlarmState::Firing)` and prepares the JavaScript callback.

### Phase 2: JavaScript Execution

The DO's `alarm()` handler executes. This runs in the JS/TypeScript environment with access to full DO storage. From **[`crates/celld/js/node_timers.js`](https://github.com/denoland/celld/blob/main/crates/celld/js/node_timers.js)**, the shim exposes the handler invocation.

### Phase 3: Completion

After the handler finishes, `alarm_finished` (in **[`crates/logic/lib.rs`](https://github.com/denoland/celld/blob/main/crates/logic/lib.rs)**) performs cleanup:

```rust
// Pseudocode representation of alarm_finished logic
fn alarm_finished(cell: &mut Cell) {
    // Reset alarm state
    cell.alarm = None;
    
    // If handler re-armed, new state already set via setAlarm
    // Clean up bucket entry
    // Update SQLite persistence
}

```

The **at-least-once guarantee** stems from this persistence: if a crash occurs mid-execution, the alarm remains in SQLite. On restart, the cell restores from `RestoredAlarm` state and re-arms the alarm.

## JavaScript API: setAlarm and alarm Handler

User code interacts with alarms through the `DurableObjectStorage` interface.

### Setting an Alarm

```javascript
export default class MyDurableObject {
  async fetch(request) {
    // Schedule alarm 5 seconds from now
    await this.storage.setAlarm(Date.now() + 5000);
    return new Response("Alarm scheduled");
  }
}

```

The `setAlarm(delayMs)` call flows:
1. JavaScript shim in **[`crates/celld/js/node_timers.js`](https://github.com/denoland/celld/blob/main/crates/celld/js/node_timers.js)**
2. Rust FFI boundary
3. `Alarm::arm` in **[`crates/logic/alarm.rs`](https://github.com/denoland/celld/blob/main/crates/logic/alarm.rs)**
4. Bucket entry creation + SQLite persistence

### The Alarm Handler

```javascript
async alarm() {
  console.log("Alarm fired");
  
  // Perform periodic work...
  
  // Re-arm for next interval
  await this.storage.setAlarm(60 * 1000); // 1 minute
}

```

The handler executes exactly once per scheduled time, with the runtime managing all concurrency and persistence concerns.

## Edge Cases and Durability Guarantees

| Scenario | celld Behavior |
|----------|---------------|
| **Cancellation** (`setAlarm(-1)`) | Removes bucket entry, sets `AlarmState::None`, clears SQLite column |
| **Node loses ownership** | Alarm entry persists in bucket; new owner restores from SQLite and re-arms |
| **Rapid re-arming** | Bucket key includes timestamp + cell ID; old entry deleted safely, new entry created |
| **Handler crash** | Alarm remains in SQLite; restored as `RestoredAlarm` on restart, re-fired |
| **Cell eviction** | `Alarm::covered` checks SQLite/bucket consistency; blocks eviction until alarm resolved |

The `Alarm::covered` method in **[`crates/logic/alarm.rs`](https://github.com/denoland/celld/blob/main/crates/logic/alarm.rs)** implements the eviction blocker. It verifies that any persisted alarm in SQLite has a corresponding bucket entry, preventing lost alarms during memory pressure.

## Debugging and Inspection

### Query SQLite Directly

```rust
let conn = cell.open_sqlite()?;
let alarm_ts: Option<i64> = conn.query_row(
    "SELECT alarm FROM celld_cell WHERE id = ?1",
    [&cell_id],
    |r| r.get(0),
).ok().flatten();

match alarm_ts {
    Some(ts) => println!("Next alarm at: {}", ts),
    None => println!("No alarm scheduled"),
}

```

This uses the SQLite helpers from **[`crates/logic/sqlite.rs`](https://github.com/denoland/celld/blob/main/crates/logic/sqlite.rs)**.

### Check Bucket State

Monitor the per-minute scanner logs or instrument `cell_alarm_timer` in **[`crates/logic/lib.rs`](https://github.com/denoland/celld/blob/main/crates/logic/lib.rs)** to observe:
- Scan frequency
- Earliest alarm timestamp
- Firing latency

## Key Source Files

| File | Responsibility |
|------|---------------|
| **[`crates/logic/alarm.rs`](https://github.com/denoland/celld/blob/main/crates/logic/alarm.rs)** | Core `Alarm` struct, `arm()`, `covered()` logic |
| **[`crates/logic/lib.rs`](https://github.com/denoland/celld/blob/main/crates/logic/lib.rs)** | `Cell` struct, `cell_alarm_timer`, `alarm_finished`, state machine |
| **[`crates/logic/sqlite.rs`](https://github.com/denoland/celld/blob/main/crates/logic/sqlite.rs)** | Per-cell SQLite schema, alarm persistence helpers |
| **[`crates/logic/wake.rs`](https://github.com/denoland/celld/blob/main/crates/logic/wake.rs)** | Bucket wake entry creation for alarm scheduling |
| **[`crates/celld/js/node_timers.js`](https://github.com/denoland/celld/blob/main/crates/celld/js/node_timers.js)** | JavaScript `setAlarm`/`clearAlarm` shim |
| **[`docs/cloudflare-compat.md`](https://github.com/denoland/celld/blob/main/docs/cloudflare-compat.md)** | DO API compatibility documentation |
| **[`docs/testing.md`](https://github.com/denoland/celld/blob/main/docs/testing.md)** | Alarm test scenarios: firing, cancellation, migration |

All files are available at [github.com/denoland/celld](https://github.com/denoland/celld).

## Summary

- **Durable Object alarms** combine in-memory buckets with SQLite for reliability
- **`Alarm::arm`** in [`crates/logic/alarm.rs`](https://github.com/denoland/celld/blob/main/crates/logic/alarm.rs) creates bucket entries and persists state
- **Global scanner** (`cell_alarm_timer`) fires due alarms once per minute
- **`alarm_finished`** cleans up state and handles re-arming
- **JavaScript API** (`storage.setAlarm`, `alarm()` handler) wraps the Rust implementation
- **At-least-once semantics** are guaranteed through SQLite persistence and `RestoredAlarm` recovery

## Frequently Asked Questions

### How does celld guarantee alarm delivery after a crash?

The alarm timestamp is written to SQLite before the bucket entry is created. On restart, any cell with a persisted alarm but no matching bucket entry enters `RestoredAlarm` state and re-arms itself. This ensures the alarm is observed again even if the previous firing never completed.

### What happens if I call setAlarm multiple times rapidly?

Each call creates a new bucket entry with key `entry_key(timestamp, cell_id)`. The cell's `AlarmState` is overwritten to the latest arm operation, and the old bucket entry is deleted safely. Only the most recent alarm timestamp remains active.

### Can I cancel a scheduled alarm?

Yes. Call `storage.setAlarm(-1)` or `storage.deleteAlarm()`. This removes the bucket entry, sets `AlarmState::None`, and clears the `alarm` column in SQLite. The global scanner automatically adjusts if this was the earliest pending alarm.

### How precise is the alarm timing?

Alarms are accurate to within the scanner's one-minute polling interval plus any execution queue latency. For sub-minute precision, the timer reschedules immediately when an earlier alarm is armed, but the minimum practical granularity is limited by the coordination between bucket scan and injection phases.