No capability in this kit does slow work inside a request. Mail, media enrichment, payment reconciliation, key rotation, the closed-test daily pass — all of them enqueue and return.
The reason is not architectural taste. A Worker has a CPU budget and a request duration, and spending either on somebody else’s API means that when their API is slow, your sign-in is slow; when it is down, your sign-in fails.
What a request does instead
Adding a scheduled job is the walkthrough, email is the capability that leans on this hardest, and Durable Objects is the other half of the runtime’s durability story.
Writes a row, and returns.
const { jobId, status, subject } = await enqueue(env, { … });The row is in your own D1. A Cloudflare Workflow picks it up and does the work — with retries, backoff and durable state that survives a Worker restart, because a Workflow’s progress is checkpointed rather than held in memory.
The request that asked for a magic link returns in milliseconds and does not care whether the message has left yet.
sequenceDiagram
actor C as Caller
participant W as Your Worker
participant DB as Your D1
participant WF as A Cloudflare Workflow
C->>W: Request
W->>DB: Insert a pending row
W-->>C: jobId, status, subject
Note over C,W: Milliseconds. Nothing has been sent yet.
WF->>DB: Picks the row up
WF->>WF: Retries, backoff, checkpointed state
WF->>DB: Marks it done
Three shapes of scheduling
Immediate. Insert a pending row and start the Workflow now.
Scheduled. Insert a row with an absolute time.
Per-recipient-local. Resolve somebody’s local time-of-day in their own timezone to an absolute time. Send at 9am wherever they are is one row per recipient with different absolute times, rather than one job that has to reason about zones while it runs.
An every-minute cron Workflow finds what is due and fans it into batches sized to the volume.
What the caller gets back
A job id, a status, and — for mail — the rendered subject.
That last one is there so a caller with an administrative trail to write does not have to re-render it, which would mean restating the theme, the layer stack and the catalog, then pinning your copy against the kit’s to notice when its wording moves.
But it is the enqueue-time render, and the send remains the authority on what was actually delivered — the Workflow renders again at the moment the message leaves and rewrites the row from that render.
Three things can part the two: a template corrected, a theme renamed, or a catalog sentence retranslated. And the gap is not only the wait in the queue — the send Workflow is its own deploy, carrying its own copy in its own bundle, so a send Worker whose copy has drifted from the Worker that enqueued stands until the two are back in step. An immediate send lands inside that window like any other.
So record what you queued. A trail that must reflect delivery reads the row back afterwards.
Sending from inside your own Workflow
A route reaches the enqueue function through the composition hook, and should keep doing that — it is typed and explicit.
A Workflow class has no such route. The runtime constructs it with the Worker’s environment and nothing else; the enqueue function is a closure rather than a binding, and Workflow parameters are serialized, so a closure cannot travel in one either.
The wrong fix is to rebuild the send identity inside the step, which puts your sending address in a second place free to drift from your config. The right one is an environment-only entry point, which resolves the same composed identity from the environment the runtime did hand you.
Every capability that owns Workflows ships a host
A capability with durable work ships a prebuilt host Worker, deployed by that capability’s own provision command — <project>-<env>-<capability>.
Which is why a Workflow binding cannot be written by pithy add: wrangler requires both a name and a class name on every entry, and the deployed name is per project and environment. An entry short of either field does not degrade — wrangler refuses to load the config at all.
So add writes none, reports it in its notes, and the provision command completes it.
pithy dev starts those hosts too, as ordinary members of the dev set with their own ports and their own labels. That is why mail sent from localhost goes somewhere: the Workflow binding named a Worker that nothing was running, so every enqueued message sat pending while the UI reported success.
Your own jobs
Your app capability can declare Workflows exactly as a library capability does, and pithy worker sync writes the bindings and the cron triggers into your wrangler.jsonc for every environment.
That sync is the step people miss, and it fails silently: a job declared and never synced ships with no binding and no trigger, so the binding fails on the first request and the cron simply never fires. Nothing errors, nothing logs, and nothing probes red.
So pithy doctor and pithy deploy both compare what the app declares against what the stanza binds, and both refuse rather than shipping it.
When a Workflow is the wrong tool
Anything the caller needs the answer to. A Workflow is for work whose result the request does not wait on.
Anything that must be exactly-once with no bookkeeping. Workflows retry, so a step that is not idempotent will run twice eventually. That is why every ledger movement carries a reference and every purchase write converges on one keyed projection.
Anything sub-second and cheap. A durable job has overhead. A single indexed read does not need one.