Cron-Triggered D1 Work Queue
Polling D1 for due rows with backoff, dead-lettering, and partial-success correctness -- no Queues binding required
When you don't need Queues
You have recurring work to do -- send reminders, poll an upstream API, retry failed deliveries -- and you reach for Cloudflare Queues. But if your job is naturally driven by a clock (fire when a row's due time arrives) rather than by an event stream, a Cron Trigger plus a next_fire_at column in D1 is simpler: the database is the queue, no extra binding, and the schedule lives in plain SQL you can inspect.
This recipe is grounded in a notifications worker that delivers reminders over email and webhooks. Every minute the cron fires, the worker pulls a bounded batch of due rows out of D1, fans each one out to its channels, and writes the outcome back -- advancing the schedule, applying backoff, or dead-lettering.
Cron Trigger Config
A single cron entry firing every minute is the heartbeat of the whole system. Each invocation drains a bounded slice of the queue.
name = "zudo-notifications-worker"
main = "src/index.ts"
compatibility_date = "2025-04-01"
[[d1_databases]]
binding = "NOTIFICATIONS_DB"
database_name = "notifications-db"
database_id = "placeholder-replace-with-actual-id"
[triggers]
crons = ["* * * * *"]There is no Queues binding here -- only D1. The [triggers] block is all that connects the clock to your scheduled() handler.
The scheduled() handler and ctx.waitUntil()
The Worker runtime considers a scheduled invocation finished as soon as the scheduled() function returns. If you kick off delivery with a bare await-less call -- or even forget to keep the runtime alive -- the runtime can tear down the worker before your asynchronous email and webhook sends complete. ctx.waitUntil() registers the promise so the runtime waits for it.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return await handleRequest(request, env);
},
async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
// Fires every minute (* * * * *). Delivers due notifications via
// Resend (email) and HMAC-signed webhooks.
ctx.waitUntil(runDeliveryTick(env));
},
} satisfies ExportedHandler<Env>;Always wrap async work in ctx.waitUntil()
Inside scheduled(), any delivery that outlives the synchronous body must be passed to ctx.waitUntil(). Without it the runtime may kill the worker mid-flight, leaving rows that look due but were never delivered -- and which then re-fire on the next tick. This is the single most common cause of "my cron silently drops work."
D1 as a poll-based queue
Each tick runs one bounded query. The next_fire_at column makes "due" expressible in SQL, and the LIMIT keeps a single invocation inside the Workers CPU-time and subrequest budget no matter how large the backlog grows.
// Max rows processed per cron tick.
const TICK_LIMIT = 100;
export async function runDeliveryTick(env: Env): Promise<void> {
const now = Date.now();
const { results } = await env.NOTIFICATIONS_DB.prepare(
`SELECT * FROM notifications
WHERE next_fire_at <= ? AND error_count < ?
ORDER BY next_fire_at ASC
LIMIT ?`,
)
.bind(now, MAX_RETRIES, TICK_LIMIT)
.all<NotificationRow>();
if (!results || results.length === 0) return;
// Process rows sequentially to avoid thundering-herd on D1.
for (const row of results) {
try {
await processRow(row, env);
} catch (err) {
// Unexpected error in processRow -- log and continue to next row.
console.error(`[scheduler] Unexpected error processing row ${row.id}:`, err);
}
}
}The query encodes two filters at once:
next_fire_at <= now-- only rows whose scheduled time has arrived.error_count < MAX_RETRIES-- skip rows that have already exhausted their retries (they are dead-lettered, see below).
ORDER BY next_fire_at ASC drains the oldest-due rows first, so a backlog clears in fairness order. If LIMIT rows come back full every tick, the next minute simply picks up where this one left off -- the backlog is self-draining.
Pick a LIMIT you can finish in one tick
A Worker invocation has a finite CPU-time and subrequest budget. Size LIMIT so the slowest realistic batch (rows times channels times per-channel timeout) fits comfortably inside it. If you routinely hit the limit, shorten the per-channel timeout or shrink the batch -- do not raise the cron frequency past one minute, which is the finest cron granularity.
Claiming rows against overlapping ticks
The query above assumes each tick has the queue to itself. Cron fires strictly every minute regardless of whether the previous tick has finished -- if a tick runs long (a slow upstream channel, a D1 hiccup, a burst of due rows), the next tick's scheduled() invocation can start while the previous one is still inside processRow(). Both invocations run the same SELECT, both see the same due rows, and without anything to stop it, both process -- and deliver -- the same row twice.
The fix is the same primitive as claim-token fencing in the D1 storage docs: a sentinel column that one tick claims atomically before it does any work, so a second tick claiming the same row loses.
ALTER TABLE notifications ADD COLUMN claimed_until INTEGER;
ALTER TABLE notifications ADD COLUMN claim_token INTEGER NOT NULL DEFAULT 0;// Bounded claim lease: long enough to cover this tick's worst case
// (TICK_LIMIT rows x per-channel timeout with margin), short enough
// that a crashed tick's rows are reclaimable well before it matters.
const CLAIM_LEASE_MS = 45_000; // cron fires every 60s
export async function runDeliveryTick(env: Env): Promise<void> {
const now = Date.now();
const { results } = await env.NOTIFICATIONS_DB.prepare(
`SELECT * FROM notifications
WHERE next_fire_at <= ?
AND error_count < ?
AND (claimed_until IS NULL OR claimed_until < ?)
ORDER BY next_fire_at ASC
LIMIT ?`,
)
.bind(now, MAX_RETRIES, now, TICK_LIMIT)
.all<NotificationRow>();
if (!results || results.length === 0) return;
for (const row of results) {
// Claim with a timestamp taken HERE, at claim time -- not the tick's
// start time. A tick that runs long must not hand out an expiry that is
// already in the past for rows near the end of the batch.
const claimedAt = Date.now();
const claim = await env.NOTIFICATIONS_DB.prepare(
`UPDATE notifications
SET claimed_until = ?, claim_token = claim_token + 1
WHERE id = ? AND (claimed_until IS NULL OR claimed_until < ?)
RETURNING claim_token`,
)
.bind(claimedAt + CLAIM_LEASE_MS, row.id, claimedAt)
.first<{ claim_token: number }>();
if (!claim) continue; // an overlapping tick already claimed it, or still holds the lease
try {
await processRow(row, claim.claim_token, env);
} catch (err) {
console.error(`[scheduler] Unexpected error processing row ${row.id}:`, err);
}
}
}Both the SELECT and the per-row UPDATE filter on claimed_until -- the SELECT keeps already-claimed rows out of the candidate list so a busy tick doesn't waste a query slot re-checking them, but the UPDATE is what actually decides ownership, because the SELECT alone can't: two overlapping ticks can both run the SELECT and both see the same unclaimed row before either one claims it. The UPDATE's WHERE clause is the real compare-and-swap; whichever tick's UPDATE lands first wins, and the loser gets no row back from RETURNING.
The claim also hands out a claim_token, incremented atomically in the same statement, and returned straight from it rather than read back with a follow-up SELECT -- the same reasoning as bounded leases in the D1 storage docs: a SELECT taken even a moment later could observe a newer reclaim's token instead of this claim's own. processRow carries that token through every write-back (the advance / backoff / dead-letter branches from the section below), fencing each one on claim_token so a row reclaimed out from under a stalled processRow() call can't have its outcome overwritten by the stale claimant. Those same write-backs should also set claimed_until = NULL on success, so a row becomes claimable again on its own next next_fire_at instead of waiting out the rest of a lease it no longer needs.
claimed_until must be a bounded lease, never an indefinitely-future value
Set claimed_until to "now plus a lease duration," never to a value far in the future meant to mean "locked until I say otherwise." A Worker instance can be evicted mid-processRow() -- a CPU-time limit, an uncaught exception, a redeploy -- with no chance to clear the claim. An unbounded sentinel strands that row forever. A bounded lease strands it only until the lease expires, and the next tick reclaims it automatically.
Say CLAIM_LEASE_MS = 45_000 against a 60-second cron, and row R is due:
Tick #1 fires at T = 0s. It selects R and claims it with a timestamp taken at that moment (
claimed_until = T + 45s,claim_tokenbumped from0to1), then startsprocessRow(R, claimToken=1)-- say the webhook channel is slow to respond.T = 50s -- the Worker instance is evicted (CPU limit, uncaught exception, redeploy) mid-delivery, before
processRowwrites back tonotifications. R is left withclaimed_until = T + 45s(already in the past),claim_token = 1, and itsnext_fire_at/error_countuntouched from before the tick started.Tick #2 fires at T = 60s. Its SELECT includes R again:
next_fire_at <= nowstill holds, andclaimed_until (T+45s) < now (T+60s)-- the lease has expired, so R is a candidate again.Tick #2 claims R (
claimed_until = T + 60s + 45s,claim_tokenbumped to2) and reprocesses it from scratch withclaimToken = 2.If Tick #1's delivery had actually gone through just before the crash -- the webhook call succeeded but the write-back never ran -- Tick #2's reprocessing sends a duplicate. This is the same hazard as the partial-success duplicate-delivery rule documented above: a lease bounds how long a stuck row is invisible, it does not by itself make delivery idempotent. Keep
CLAIM_LEASE_MStight against the realistic worst case so this window stays small, and where a duplicate send is unacceptable, make the delivery itself idempotent rather than relying on the lease alone.If Tick #1's Worker instance had instead merely stalled rather than been evicted -- still alive, still holding
claimToken = 1, just slow -- and it finally tries to write back after Tick #2 has already reclaimed and possibly completed R, its write-back statements are fenced onclaim_token = 1. The row'sclaim_tokenis now2, so every one of Tick #1's writes matches zero rows and is silently rejected -- Tick #1's stale result can never overwrite whatever Tick #2 (or a later tick still) wrote.
Backoff and dead-lettering -- all in D1
There is no separate retry queue. A row's retry state lives in two columns: error_count (how many times it has failed) and next_fire_at (when to try again). The backoff schedule and the dead-letter threshold are pure functions of error_count.
// Backoff schedule: 1st failure -> +1 min, 2nd -> +5 min, 3rd -> +30 min.
// error_count is the count BEFORE this failure (0-based), so:
// error_count === 0 -> next retry in 1 min
// error_count === 1 -> next retry in 5 min
// error_count === 2 -> next retry in 30 min
const BACKOFF_MS = [
1 * 60 * 1000, // 1 min
5 * 60 * 1000, // 5 min
30 * 60 * 1000, // 30 min
] as const;
/** Maximum number of delivery attempts before a row is dead-lettered. */
export const MAX_RETRIES = 3;
export function retryNextFireAt(now: number, errorCount: number): number {
const backoff = BACKOFF_MS[Math.min(errorCount, BACKOFF_MS.length - 1)];
return now + backoff;
}
export function isDeadLettered(errorCount: number): boolean {
return errorCount >= MAX_RETRIES;
}When a row fails fully, you increment error_count and push next_fire_at out by the backoff interval. Once error_count reaches MAX_RETRIES, the row is dead-lettered: a one-shot row sets next_fire_at = NULL (the due query's next_fire_at <= now can never match NULL, so it is permanently parked for inspection), while a recurring row simply skips the failed fire and advances to its next regular schedule. Because the due query already filters error_count < MAX_RETRIES, a dead-lettered row is invisible to future ticks without any extra bookkeeping.
The partial-success rule (read this twice)
This is the subtle correctness point that bites. A row can fan out to several channels -- email and a webhook. Three outcomes matter:
Full success -- every channel delivered.
Full failure -- every channel failed.
Partial success -- some delivered, some failed.
Increment the error counter ONLY on full failure -- never on partial success
If you treat partial success like a failure and schedule a retry, the next attempt re-sends every channel -- including the ones that already succeeded. The user gets the same email twice. A duplicate delivery is worse than a missed one: silence is recoverable, a double-send is not. So on partial success you advance next_fire_at to the next regular fire time, record the partial error for observability, and leave error_count untouched. The failed channel is not retried sooner; it simply gets its normal next turn.
The classification is a one-liner per outcome:
const failedChannels = Object.keys(channelErrors);
const succeededChannels = channels.filter((ch) => !channelErrors[ch]);
const isFullFailure = succeededChannels.length === 0 && failedChannels.length > 0;
const isPartialSuccess = succeededChannels.length > 0 && failedChannels.length > 0;
const isFullSuccess = failedChannels.length === 0;And the write-back branches on it. Note that both full success and partial success advance the schedule the same way -- the only difference is that partial success records a last_error string and full success clears it. Crucially, neither one touches error_count in the retry sense; the recurring path even resets it to 0.
Every branch also carries claimToken -- the value returned from the claim in the previous section -- through its WHERE clause, and checks meta.changes. If processRow() outlives its lease, a later tick can reclaim the same row and bump claim_token again while this call is still running; without the fence, this stale call's write-back would land unconditionally by id and could clobber the new claimant's outcome, or clear a claimed_until that isn't this call's to clear. A meta.changes === 0 here means exactly that: the row moved on without us, so this outcome is stale and must not be applied.
if (isFullSuccess || isPartialSuccess) {
const nextFire = nextFireAt(new Date(now), recurrenceRule);
if (nextFire === null) {
// One-shot: delete the row on success -- fenced on claim_token so a
// row reclaimed by a later tick is never deleted out from under its
// new owner.
const del = await env.NOTIFICATIONS_DB.prepare(
"DELETE FROM notifications WHERE id = ? AND claim_token = ?",
).bind(row.id, claimToken).run();
if (del.meta.changes === 0) {
console.error(`[scheduler] fenced out on delete for row ${row.id}, claim_token=${claimToken}`);
}
} else {
// Recurring: advance to next fire time. error_count resets to 0;
// a partial failure is recorded in last_error but is NOT a retry.
const lastError = isPartialSuccess
? `partial: ${failedChannels.join(", ")}`
: null;
const advance = await env.NOTIFICATIONS_DB.prepare(
`UPDATE notifications
SET next_fire_at = ?,
last_fired_at = ?,
error_count = 0,
last_error = ?,
claimed_until = NULL,
updated_at = ?
WHERE id = ? AND claim_token = ?`,
)
.bind(nextFire.getTime(), now, lastError, now, row.id, claimToken)
.run();
if (advance.meta.changes === 0) {
console.error(`[scheduler] fenced out on advance for row ${row.id}, claim_token=${claimToken}`);
}
}
} else if (isFullFailure) {
// Only here does error_count climb and backoff / dead-lettering apply.
const newErrorCount = row.error_count + 1;
if (isDeadLettered(newErrorCount)) {
// Park (one-shot: next_fire_at = NULL) or skip-forward (recurring).
} else {
const nextRetryAt = retryNextFireAt(now, row.error_count);
const retry = await env.NOTIFICATIONS_DB.prepare(
`UPDATE notifications
SET next_fire_at = ?,
error_count = ?,
last_error = ?,
claimed_until = NULL,
updated_at = ?
WHERE id = ? AND claim_token = ?`,
)
.bind(nextRetryAt, newErrorCount, errorSummary, now, row.id, claimToken)
.run();
if (retry.meta.changes === 0) {
console.error(`[scheduler] fenced out on backoff for row ${row.id}, claim_token=${claimToken}`);
}
}
}The single rule to remember: error_count is a retry counter, and a retry re-runs the whole row. Only a full failure earns a retry. Partial success advances the schedule like a success because re-running it would duplicate the channels that already worked.
Per-row delivery with timeouts
Each channel send is bounded by a timeout so one hung endpoint cannot stall the whole tick. The fan-out runs the channels in parallel and collects per-channel errors into a map, which is exactly what the partial-success classification above reads from.
// 10-second per-channel fetch timeout.
const CHANNEL_TIMEOUT_MS = 10_000;
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`operation timed out after ${ms}ms`)), ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}
// Inside processRow: fan out, recording a message per failed channel.
const channelErrors: Record<string, string> = {};
await Promise.all(
channels.map(async (channel) => {
let err: string | null = null;
if (channel === "email") {
err = await tryDeliverEmail(row, env.NOTIFICATIONS_RESEND_KEY);
} else if (channel === "webhook") {
err = await tryDeliverWebhook(row, channels, firedAt);
}
if (err !== null) {
channelErrors[channel] = err;
}
}),
);A channel helper returns null on success or an error string on failure -- never throws -- so a single bad channel degrades to a partial success rather than crashing the row.
Why this beats reaching for Queues
| Concern | Cron + D1 | Queues |
|---|---|---|
| Extra binding | None | Queue producer + consumer |
| Inspecting pending work | SELECT * FROM notifications | Queue is opaque |
| Schedule semantics | next_fire_at column, plain SQL | Delay per message |
| Backoff / dead-letter | Two columns, pure functions | Built-in DLQ, less visible |
| Best fit | Clock-driven, due-time work | High-throughput event streams |
Queues shine when work arrives as a high-volume event stream you want to buffer and process at your own pace. For clock-driven work where every item has a due time, a cron heartbeat over a D1 table is less machinery, fully inspectable, and keeps the retry policy in code you can unit-test.
For the immediate-response webhook pattern that also leans on ctx.waitUntil(), see Bot Worker Pattern. For D1 fundamentals -- including multi-writer concurrency and atomic dedupe and claims -- see the storage docs.