Workflows
Every durable job in Cusp is a Cloudflare Workflow with independently retryable steps
Anything too slow, too big, or too important to do inside a request runs as a Cloudflare Workflow. Publishing a document, restoring the whole site to an earlier point in time, importing a backup, taking the nightly backup: each runs as a Workflow with independently retryable steps. The request that starts the work returns at once; the durable execution happens out of band and survives Worker restarts.
The async work model
A Workflow is a class with a run method composed of named steps. Each step is wrapped in step.do, runs independently, and is retried on its own budget with exponential backoff. If a step fails, only that step retries — the steps before it have already committed their results and are not re-run. If the whole instance fails, the system is left in a consistent state: a document that failed to render is still marked published in D1, and the public render path falls back to live SSR on the next request, so a visitor never sees a broken page.
A Worker handler creates an instance through its binding, for example env.PUBLISH.create with the document and collection ids as params. Creation is sub-millisecond, so the editor sees confirmation in the admin straight away while the rendering, sitemap regeneration, and cache purge run inside the instance.
- Independently retryable — a transient D1 throttle or R2 hiccup recovers without manual intervention and without losing the work earlier steps did.
- Resumable — long batch jobs persist a cursor in D1 so a retried step picks up exactly where the previous attempt stopped, never re-doing committed rows.
- Observable — step state is readable through the Workflows REST API and the Cloudflare dashboard. The admin Health page and the publish-status indicator both read it, so an editor can see which step is running.
- Generous step ceiling — each step has its own multi-minute CPU window, and step.waitForEvent is available for anything that needs to pause longer.
No queues, no cron polling
Cusp uses no Cloudflare Queues and no cron triggers. Periodic and deferred work runs inside Workflows instead, using step.sleepUntil. A scheduled publish is a Workflow that sleeps until the publish time and then runs the standard publish steps. Sleeping instances do not count toward the active-instance concurrency ceiling, so a million documents scheduled for the future cost effectively nothing, and there is no scheduling state machine to maintain.
Recurring jobs follow the same pattern: a single long-lived instance is created once at install time, runs its work, sleeps until the next run with step.sleepUntil, and loops. The loop fails loudly if it dies — the Health page surfaces a missing instance — whereas a cron trigger would double the failure surface and give no per-instance observability. There is no triggers.crons entry in the Wrangler config and no scheduled() handler in the Worker.
The twelve Workflows
Twelve Workflow classes ship in core. Each has a Wrangler binding — the uppercase name in brackets — that the Worker uses to create instances.
- PublishDocument (PUBLISH) — the marquee pipeline behind the public render path. In order: verify the document is still published, render it to R2, re-render the listings it appears in, index it for search, notify subscribed plugins, purge the affected surrogate keys, then regenerate the sitemap and feeds through a debounced aggregates step that issues a second conditional purge once the aggregates are rewritten.
- UnpublishDocument (UNPUBLISH) — the inverse: removes a document from the public render tree, refreshes affected listings, and purges the relevant surrogate keys.
- ScheduledPublish (SCHEDULE) — created when an editor schedules a document. Sleeps until the scheduled time with step.sleepUntil, re-confirms the document is still scheduled, then runs the publish steps.
- ImportContent (IMPORT) — the restore-from-backup engine. Reads a backup tarball already in R2, verifies its manifest and per-table SHA-256 hashes against the running runtime, refuses if the target is not empty, and inserts the D1 dump FK-parent-before-child in chunks of fifty with cursor-resume on retry.
- MigrateField (MIGRATE) — changes the type of an existing field on a collection that already holds documents. Converts each document's value through a pure conversion-rule registry in batches of fifty, then commits the field's new type only after every batch succeeds; resumes from a persisted cursor on failure.
- ExportBackup (EXPORT) — the nightly backup engine. A long-lived self-scheduling instance that wakes at 03:00 UTC, dumps every D1 table to JSONL, inventories the R2 media and rendered trees, writes a manifest with per-table SHA-256 hashes, and stores one gzipped tarball at backups/{timestamp}/backup.tar.gz. A manual Run backup now button creates a one-shot instance.
- InstallPackage (INSTALL) — installs a theme or a plugin from a signed package. Downloads it, verifies the signature, writes it to R2, and registers the row and any contributed block definitions; the same class dispatches on the package kind.
- RestoreVersion (RESTORE) — site-wide time-travel restore. Walks every versioned entity and mints a new version equal to its state at a chosen timestamp, using a two-pass forward-mint then tombstone-delete shape. The current state is preserved as the version immediately before the restore, so the restore is itself reversible.
- SeedExampleSite (SEED) — seeds the worked example site (the Royal Society of Pheasant Patissiers) durably with step-level retries, attributing every versioned write to the admin who triggered it. Created from the first-run wizard, the admin's Seed sample content action (POST /api/admin/example-site/seed), or the contributor CLI.
- PrunePolicies (PRUNE) — a long-lived self-scheduling instance that wakes daily at 04:00 UTC (one hour after the backup, so a backup captures pre-prune state), walks entity_versions per entity type, and hard-deletes rows beyond the configured cap in chunks of fifty, best-effort deleting each pruned version's R2 spillover blob. There is no archive-before-delete step — the 03:00 UTC ExportBackup running before the 04:00 prune is the protection story.
- SweepMedia (SWEEP_MEDIA) — a long-lived self-scheduling instance that wakes daily at 02:00 UTC, finds soft-deleted media past its retention window, confirms no live document still references it, and hard-deletes the row and the R2 object.
- RebuildSearchIndex (REBUILD_SEARCH) — replays every live published document through the search index repository to rebuild the FTS5 search_index from scratch. Triggered on first boot after the search table lands and manually from the admin Health page. The first-boot enqueue no-ops if the index is already populated; the manual Health-page rebuild passes force and repopulates the index unconditionally.
Self-scheduling instances
Three Workflows run as long-lived instances created once at install time: SweepMedia at 02:00 UTC, ExportBackup at 03:00 UTC, and PrunePolicies at 04:00 UTC each self-schedule with step.sleepUntil and loop forever. ScheduledPublish also sleeps, but as a single-shot instance minted per scheduled document rather than a recurring loop. The admin Health page surfaces the persisted ExportBackup and PrunePolicies instance ids so you can confirm each recurring loop is still alive; it reports liveness only and does not offer a restart action.
ExportBackup and ImportContent guard against stepping on each other with an insert-then-verify peer check: each side claims its own singleton row first, then checks whether the other is running. A backup iteration that wakes mid-restore yields the day cleanly rather than dumping a half-restored database.
Next: Commands