Skip to content
← All writing

Alarm-driven task queues without a queue service

5 min read
.md
Cover illustration for Alarm-driven task queues without a queue service

A Durable Object alarm is a per-object timer with at-least-once delivery and automatic retry. Give each task its own object and the alarm becomes a scheduler, a retry policy and a concurrency limit of one, with no queue service to run. It stops working once you need cross-task ordering or fan-in.

Someone asks my agent to summarise a 60-page PDF. The answer will not be ready before the request times out, so the work has to keep going after the user has gone. Now I need somewhere to park that job, something to pick it up, and a promise that two workers will never grab it at once.

I have built that four times. Always the same shape: a jobs table with a status column, a worker polling it, a lock so nothing gets picked up twice, and a retry counter nobody quite trusts.

Every version leaked somewhere. The lock expires while the work is still running, or the retry counter increments on a crash before the work is recorded, and you get a double charge.

The version I stopped rewriting uses Durable Object alarms. One object per task. The alarm is the scheduler.

What you get for free

An alarm is just a timestamp you store with the object. When the time arrives, the platform wakes the object up and calls its alarm() method. Cloudflare is specific about what that promises: alarms run at least once, and get retried with exponential backoff until they succeed.

Read that carefully, because three of the four things I kept hand-rolling are already in there.

The object only runs one thing at a time, so nothing can pick up the same job twice. No lock. The alarm is the schedule, so nothing sits in a loop asking an empty table whether there is work yet. And if the handler throws, the platform backs off and calls again, so the retry logic is not mine to get wrong.

import { DurableObject } from 'cloudflare:workers'

export class TaskDO extends DurableObject {
  sql: SqlStorage

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env)
    this.sql = ctx.storage.sql
    this.sql.exec(`
      CREATE TABLE IF NOT EXISTS task(
        id         TEXT PRIMARY KEY,
        payload    TEXT NOT NULL,
        attempts   INTEGER NOT NULL DEFAULT 0,
        status     TEXT NOT NULL DEFAULT 'pending',
        created_at INTEGER NOT NULL
      );
    `)
  }

  async enqueue(id: string, payload: unknown, delayMs = 0) {
    this.sql.exec(
      `INSERT OR REPLACE INTO task (id, payload, created_at) VALUES (?, ?, ?)`,
      id,
      JSON.stringify(payload),
      Date.now()
    )
    await this.ctx.storage.setAlarm(Date.now() + delayMs)
  }

  async alarm() {
    const task = this.sql.exec(`SELECT * FROM task WHERE status = 'pending' LIMIT 1`).toArray()[0]
    if (!task) return

    // Record the attempt before doing the work. A crash mid-run must not
    // look like an attempt that never happened.
    this.sql.exec(`UPDATE task SET attempts = attempts + 1 WHERE id = ?`, task.id)

    try {
      await this.run(JSON.parse(task.payload as string))
      this.sql.exec(`UPDATE task SET status = 'done' WHERE id = ?`, task.id)
    } catch (err) {
      if ((task.attempts as number) >= 4) {
        this.sql.exec(`UPDATE task SET status = 'dead' WHERE id = ?`, task.id)
        return
      }
      throw err // let the platform back off and call us again
    }
  }
}

The line I get wrong every time I write this from memory is the attempt counter. Increment it before the work, not after. If you increment on success you cannot tell a task that has never run from one that has crashed six times, and the dead-letter branch never fires.

Why does everything fire at once?

Because you scheduled it that way. If a thousand objects all set an alarm for the top of the hour, a thousand objects wake at the top of the hour, and whatever they call downstream sees a thousand simultaneous requests.

The fix is to add randomness to the delay, which AWS documents thoroughly in Timeouts, retries and backoff with jitter. In my platform the heartbeat scan that checks agent follow-up conditions runs on a rolling window rather than a fixed clock time, for exactly this reason.

const base = 60_000
const jitter = Math.floor(Math.random() * base * 0.5)
await this.ctx.storage.setAlarm(Date.now() + base + jitter)

Half the base interval is enough spread for a scan of a few thousand objects. It is not enough if your fan-out is in the millions, at which point you want a real queue in front.

One object per task, or one object per queue?

Per task. I have run both and the shared-queue version reintroduces the problem the alarm was supposed to remove.

One object per taskOne shared queue object
ConcurrencyOne worker per task, freeSerialised across all tasks
Retry isolationPer taskA poison task blocks everything behind it
Storage lifecycleDelete the object when doneOne database that grows forever
Cross-task orderingNot availableFree
Cost per idle taskZeroZero

The shared version looks cheaper because there is one object instead of thousands. In practice serialisation is the thing that hurts. A single task that takes ninety seconds stalls every task queued behind it, and the alarm cannot help because there is only one alarm.

Cloudflare’s own Queues product moved onto Durable Objects and reported a roughly 10x throughput improvement, which is a reasonable signal that the primitive holds up under real load.

Where this stops being the right tool

Alarms give you a timer, not a queue. Three things they do not give you:

  1. Ordering across tasks: each object knows only its own alarm. If task B must run after task A, you need the parent to chain them, and now you have written a workflow engine.
  2. Fan-in: waiting for twenty tasks to finish before running a twenty-first means somewhere has to count completions. That somewhere is another object, and it needs its own alarm as a timeout in case one task never reports.
  3. Backpressure: nothing stops you creating a million objects. Storage is metered per object and a lifecycle policy is your job, not the platform’s.

Ordering and fan-in are where I would reach for Workflows or a real queue. Everything below that, which in my experience is most background work, is one object and one timer.

Sources

Written by Elson Tan, Head of Technology and co-founder at Nedex Group, working on AI harness and agent infrastructure.

AboutRSS
  • 4 min read

    One Durable Object per agent

    Why I gave every AI agent its own Durable Object with a SQLite task ledger, and what that bought in state, concurrency and debuggability.

  • 4 min read

    Crawling a knowledge base without a crawler service

    Rendering every page in a headless browser is the expensive way to import a website. Discovery through robots.txt and sitemaps costs almost nothing, and most pages never need a browser at all.

  • 6 min read

    The job was never the code

    Most of my code is now written by an agent, and my output went up rather than down. That is not a story about typing speed. It is about what the job always was underneath the typing.

Get in touch

Tell me who you are and what you are working on.

Your details are used only to reply to this message.