You need: a scaffolded project.
Declare it in your app capability
Durable jobs is what it becomes, pithy worker sync is what writes the cron into the stanza, reading your logs is how you watch it, and diagnosing a broken project is what to run when it never fires.
A job is a Workflow plus a schedule. You declare both, and pithy worker sync writes them into wrangler.jsonc.
pithy worker syncIdempotent, comment-preserving, all-or-nothing. Run it as often as you like.
The rule that surprises people
A Worker has one scheduled handler, and it starts every job that declares a schedule — whatever cron fired.
So an expression nothing declares is not an extra job. It is every job running again, at a time nobody asked for.
Which is why sync sets the cron list to exactly the declared schedules rather than merging.
Replaced, not merged
The app’s entries are replaced. A job renamed or dropped leaves — including the last one, so an app declaring no Workflows still has its stale bindings and crons taken out.
Otherwise dropping the last job would leave a fault naming a command that could not fix it.
An entry carrying a script_name belongs to a library capability’s provisioner and is never touched. Email’s send Worker, storage’s sweep, vector’s reprocess — those are provisioned, not synced.
Nothing is invented for a project that never had either. No empty workflows key, no triggers block.
The entrypoint stays yours
The WorkflowEntrypoint subclass is yours to export from the Worker’s main. sync writes the binding and the schedule; it does not write your code.
Use durable steps
await step.do("fetch-page-1", async () => { … });Steps are journalled, so an instance that dies resumes at the step it reached rather than starting over.
Derive step names deterministically — from a page counter, not a clock or a random source. A replay must ask for the same steps in the same order, or the journal is unusable.
Classify your faults
An unclassified throw is terminal. If a fault is genuinely retryable — an upstream that was busy, a rate limit whose next window is a different answer — raise it as a code the classifier knows, or it will never be retried.
That is not theoretical: it is exactly the bug the media capability’s enrichment hit, where the one retryable fault was the only one that never got a retry.
Pace a long walk with keyset pagination
If your job mutates the rows its own predicate selects, LIMIT/OFFSET silently skips. Page two starts at row 1,000 of a set that just lost its first 1,000 members.
Read id > cursor in id order. It cannot skip and cannot repeat, whatever the predicate does behind the cursor.
Do not reach for a timer
In a Durable Object, a single setInterval prevents hibernation entirely and bills duration continuously. Use an alarm.
In a Worker, a cron is the timer.
Logging from a job
c.var.log is a request thing. In a Workflow, build one and bind the run context to it so every record carries the instance — otherwise a failure is a line with no way back to which run produced it.