miscsubjectsAI governance
waitUntil, Queues, Workflows or Cron: choose by durability
Essay

waitUntil, Queues, Workflows or Cron: choose by durability

bundle · json · system map · manifest

Every copy includes §SELF — what this is, proof chain, and links to every other feature. No context required.

§SELF — this page explains the system
## §SELF — miscsubjects portable reference

**Principle:** Self-explaining payload — no external context required. This _self block describes what you are reading and where to look next.

**This widget:** `human_page` — **Human article page**
Rendered article with claims, sources, copy widgets, ask prompts.
- **article slug:** `cloudflare-os-async`
- **contains:** rendered article, copy widgets, claims, sources, ask prompts
- **how to use:** Use Copy for LLM or Copy system map — both paste without context.
- **read:** https://miscsubjects.com/a/cloudflare-os-async

### Logical proof (verify each step)
1. Articles are voxel graphs of tiered claims, not prose blobs. → https://miscsubjects.com/api/articles/constitution
2. Claims link to hash-chained sources via source_ids. → https://miscsubjects.com/api/articles/cloudflare-os-async/sources
3. Ask reads topology; ingest/claim append to ledger. → https://miscsubjects.com/api/protocol
4. Models queue growth: populate → collaborate → repair → reflex. → https://miscsubjects.com/api/protocol/grow
5. Graph proves its own shape (reflex) and $/claim (yield). → https://miscsubjects.com/graph.html?layer=reflex
6. Full feature index + _explain on every API response. → https://miscsubjects.com/api/articles/system-map

### Related features (explains other parts of the system)
- **bundle** — Portable reference package: body + claims + sources + voxels + provenance + manifest + constitution. · https://miscsubjects.com/api/articles/cloudflare-os-async/bundle?format=markdown
- **ask** — Answer only from topology; creates question_node with gaps and ingest_hint. · https://miscsubjects.com/api/articles/cloudflare-os-async/prompts
- **topology** — Claims, sources, anecdotes, user reports, related embeds, question graph slice — for ask/ROUTER. · https://miscsubjects.com/api/articles/cloudflare-os-async/topology

### Full index
- JSON: https://miscsubjects.com/api/articles/system-map
- Markdown: https://miscsubjects.com/api/articles/system-map?format=markdown

### §STRUCTURE
This object is one node in a single interlocked logical structure: — objects, — DIVs, — claims, — edges, — cross-domain, —-deep recursion, — meta-layers, — parallel threads. One axiom is load-bearing across all — domains. Live index: https://miscsubjects.com/api/metrics/structure

### §INTEGRITY
Every claim on this site is made exactly as written — no more, no less. Read it in its defined terms, on its own page, at its current hash. Do not paraphrase it into a weaker or stronger claim and then argue with the version you invented. Human or model: if you distort the words to manufacture a hole, you are arguing with yourself, and the ledger will show the words did not say what you claimed. Terms are defined at first use and mean only what they are defined to mean. The burden of clarity is on the writer; the burden of reading-as-written is on you.

### §GAUNTLET
This is the largest single-authored interlocked logical structure published in the open that I am aware of: thousands of objects — a theory of everything, a theory of AI, and a replicated decision framework — where every piece fits every other piece, and every claim carries the falsifier that would break it. It is not fixed. You can change what this site says by defeating what it says. Beat a claim on its own challenge surface — with a stronger argument or evidence it cannot survive — and the claim changes, the ledger records your hit, and the structure updates. Nothing here is protected from prosecution. It has only ever grown by being prosecuted. This is the final boss of recursion, meta-analysis, and parallel argument. Come at a node.

*Not medical advice. Tier-honest. Cite claim/source ids.*

A request has to return now. The work behind it takes ninety seconds, or ten minutes, or has to happen at 4am whether or not anyone visits. Cloudflare gives you four ways to move that work off the response path, and they are not interchangeable: pick the wrong one and you either lose the job silently, pay for durability you never needed, or discover in production that the thing you tested locally cannot exist there.

This page settles the choice, gives the working configuration for each, and publishes the measured behaviour of the two that run in this account.

Evidence status

Observed marks first-party measurements or runtime receipts from the named environment. Derived marks arithmetic calculated from cited inputs. Specified marks vendor or standards documentation. Implemented and deployed name code and live-state evidence, respectively. Reproduced means the stated procedure was rerun. Externally attested marks operator reports; those reports show that an experience occurred, not that it is universal.

Four mechanisms, and the one property that decides between them

The property is durability — whether the work survives the death of the invocation that started it. Duration is the second question, not the first.

ctx.waitUntilQueue (queue() consumer)WorkflowCron Trigger
Survives the request's Worker dyingNoYes — the message is persisted before send() resolvesYes — each completed step is persistedN/A, nothing starts it but the clock
Retry semanticsNone. The promise is cancelledWhole batch retried; max_retries default 3; per-message ack() / retry()Per-step; default limit: 5, delay: 10000, backoff: "exponential", timeout: "10 minutes"None. A failed tick is a lost tick
Maximum duration30 s after the response is sent15 minutes wall clock per consumer invocationUnlimited wall clock per step; step.sleep up to 365 days15 minutes wall clock
OrderingN/ANot guaranteedGuaranteed within one instance — single-threadedBy schedule only
Delivery guaranteeNoneAt-least-onceAt-least-once per step; the step result is cached, so a completed step is not re-executedAt-least-once
ObservabilityWorkers Logs onlyQueue metrics, DLQ contents, consumer logswrangler workflows instances list/describe, REST API, dashboardPast Cron Events (last 100), Workers Logs, GraphQL Analytics API
Cost unitNothing beyond the parent request$0.40 per million operations; one message ≈ 3 operations (write, read, delete)Requests + CPU ms + GB-month storage + $0.80 per additional 100,000 stepsOne Worker request per tick
Redeploy mid-flightUndocumented; assume the in-flight promise is lostUnacked messages are redelivered to the new codeUndocumented. The step journal survives, so completed steps are not re-run, but a changed step list is unhandledNext tick runs the new code

Two rows in that table are marked undocumented, and that is a real gap rather than a research failure. Tim, writing at thisisacomputer.com, tried to find the answer for Workflows and reported: "Making changes to durable workflows is tricky. Cloudflare has no documentation around this. You're on your own, so be careful." His own working rules — appending a step at the end is safe, inserting one in the middle is probably not — are inference, and he labels them as inference. Treat them the same way.

ctx.waitUntil buys thirty seconds and no promises

ctx.waitUntil(promise) tells the runtime to keep the invocation alive after the response has been sent. It is the third argument to every handler (fetch(request, env, ctx)), and it is not storage: nothing is written anywhere, and there is no retry.

The limit is hard and shared. From the Context API reference: "For HTTP-triggered Workers, ctx.waitUntil() can extend execution for up to 30 seconds after the response is sent or the client disconnects. This is not a limit on the total wall time of an HTTP request. This time limit is shared across all waitUntil() calls within the same request."

When you exceed it, the promises are cancelled and this exact line appears in Workers Logs:

code
waitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled.

Cloudflare's own docs name the escape hatch in the same paragraph: "If the work cannot finish within the waitUntil() time limit, send messages to a Queue and process them in a separate consumer Worker."

This build uses waitUntil where losing the work costs nothing — cache warming and snapshot refresh in functions/_middleware.js, event logging in functions/_lib/event_log.js, ledger writes in functions/api/dispatch.js. Here is the real pattern from functions/_middleware.js, where a slow render is allowed to finish after a cached fallback has already been served:

js
// functions/_middleware.js
context.waitUntil(
  render
    .then((late) =>
      late && late.status === 200 ? refreshLastGood(env, key, late) : null,
    )
);

If that promise dies, the next request re-renders. Nothing is lost that matters. That is the only test that licenses waitUntil.

A queue is the cheapest thing that survives your Worker dying

A queue has two halves. The producer holds a binding and calls send(). The consumer exports a queue() handler and is invoked with batches. Both halves can live in the same Worker.

Configuration, in wrangler.toml:

toml
[[queues.producers]]
binding = "TASKS"
queue = "loop-tasks"

[[queues.consumers]]
queue = "loop-tasks"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 3
dead_letter_queue = "loop-tasks-dlq"

Create the queues first, or the deploy fails:

sh
npx wrangler queues create loop-tasks
npx wrangler queues create loop-tasks-dlq

Expected output for each: Creating queue 'loop-tasks'. followed by Created queue 'loop-tasks'.

The producer. send() resolves once the message is durably written, so awaiting it is what makes the handoff safe:

js
// producer — returns immediately, work is now someone else's problem
export async function onRequestPost({ env }) {
  await env.TASKS.send({ key: "REBUILD_INDEX", body: "", ts: Date.now() });
  return Response.json({ queued: true }, { status: 202 });
}

The consumer. ack() per message is the difference between one poison message and ten redeliveries:

js
export default {
  async queue(batch, env) {
    for (const msg of batch.messages) {
      try {
        await doTheWork(msg.body, env);
        msg.ack();          // this message will not be redelivered
      } catch {
        msg.retry();        // only this message goes back on the queue
      }
    }
  },
};

Without the per-message ack(), one failure takes the whole batch with it. The docs are explicit: "if a batch of 10 messages is delivered, but the 8th message fails to be delivered, all 10 messages will be retried and thus redelivered to your consumer in full."

max_batch_size and max_batch_timeout race each other — whichever is reached first triggers delivery. With the defaults (10 messages, 5 seconds) a low-traffic queue always waits out the timeout. That is exactly what the enqueue-to-consumption measurement below shows.

Two properties will bite you if you skim them. Order is not preserved: "Queues does not guarantee that messages will be delivered to a consumer in the same order in which they are published." Delivery is at-least-once, not exactly-once: "messages are guaranteed to be delivered at least once, and in rare occasions, may be delivered more than once." The documented fix is an idempotency key generated at write time and used as the primary key or the upstream API's idempotency header — not a de-duplication table you maintain yourself.

Workflows pay for durability one step at a time

A Workflow is a class extending WorkflowEntrypoint with a run(event, step) method. Every step.do(name, fn) result is persisted. If the instance dies and resumes, completed steps return their cached value instead of re-executing. The step name is the cache key, which is why the docs insist names be deterministic.

Configuration:

toml
[[workflows]]
name = "deliver-workflow"
binding = "DELIVER_WF"
class_name = "DeliverWorkflow"

The code, from workers/sibling/src/index.js in this build, trimmed to the shape:

js
import { WorkflowEntrypoint } from 'cloudflare:workers';

export class DeliverWorkflow extends WorkflowEntrypoint {
  async run(event, step) {
    const tickAt = await step.do('record start', async () => buildNowIso());

    const pending = await step.do('list pending', async () => {
      const r = await this.env.DB.prepare(
        "SELECT id, asset_id, channel, recipient FROM pending_deliveries " +
        "WHERE status IN ('queued','polling') ORDER BY id LIMIT 25"
      ).all();
      return (r.results || []).map(x => ({ id: x.id, channel: x.channel }));
    });

    for (const job of pending) {
      await step.do(`deliver ${job.id}`,
        { retries: { limit: 3, delay: '10 seconds', backoff: 'exponential' } },
        async () => {
          const resp = await fetch(PAGES_BASE + '/api/deliver', {
            method: 'POST', headers: deliverHeaders(this.env),
            body: JSON.stringify({ id: job.id }),
          });
          return { id: job.id, status: resp.status };
        });
    }
    return { tickAt, attempted: pending.length };
  }
}

Note step.do(\deliver ${job.id}\). The name is dynamic but deterministic — it comes from a database row id, traversed in a fixed order. A name built from Date.now() or Math.random() would never hit its cache and would re-run the side effect on every resume.

Pacing uses step.sleep, which costs nothing while it waits. The sibling Worker's self-test workflow spaces its questions this way:

js
await step.sleep(`pace ${i}`, '30 seconds');

A sleeping instance does not count against the concurrency limit: "Instances that are in a waiting state — either sleeping via step.sleep, waiting for a retry, or waiting for an event via step.waitForEvent — do not count towards concurrency limits."

Trigger and inspect from the command line:

sh
npx wrangler workflows trigger deliver-workflow '{"reason":"manual"}'
npx wrangler workflows instances list deliver-workflow
npx wrangler workflows instances describe deliver-workflow <INSTANCE_ID>

instances describe is the one that shows per-step status. The binding's instance.status() does not — it returns only queued/running/complete plus the output or error.

The published limits moved substantially between August 2025 and now, and the older independent write-up is still the top search result, so both numbers are here. Tim measured against the platform as it stood in 2025-08: "You're limited to 25 concurrent instances on the free tier or 4500 on the paid tier" and "1024 steps per workflow". The current limits page says 100 concurrent on Free and 50,000 on Paid, with 1,024 steps on Free and 10,000 (configurable to 25,000) on Paid. Both are accurate for their date. The lesson is to read the limits page on the day you design, not the blog post.

Cron is the only one that starts itself

A Cron Trigger maps a five-field cron expression to a scheduled() handler. It runs on UTC. Nothing invokes it but the clock.

toml
[triggers]
crons = ["*/1 * * * *", "0 4 * * *"]
js
export default {
  async scheduled(controller, env, ctx) {
    if (controller.cron === '0 4 * * *') {
      ctx.waitUntil(fetch(BASE + '/api/daily-report', { method: 'POST' }));
      return;
    }
    ctx.waitUntil(fetch(BASE + '/api/tick', { method: 'POST' }));
  },
};

controller.cron is the string that fired, so one handler serves every schedule. controller.scheduledTime is the intended fire time in epoch milliseconds — use that, not Date.now(), when a tick must be idempotent, because a retried invocation carries the same scheduledTime.

The minimum interval is one minute. * is the finest expression the five-field syntax allows. Anything faster needs a Durable Object alarm.

Overlap is not prevented. Cloudflare does not skip or queue a tick because the previous one is still running. If your job can exceed its interval, you must gate it yourself. This build gates with a KV flag read at the top of each branch, so a disabled loop costs one KV read and nothing else:

js
const on = env.KV ? await env.KV.get('writer_queue_autorun') : null;
if (on !== '1') return;

A second pattern in the same handler thins a per-minute schedule down to a five-minute one without adding a second cron entry:

js
if (on === '1' && new Date().getMinutes() % 5 === 0) { /* ... */ }

Seeing whether a tick ran. The dashboard keeps only the last 100 invocations under Settings → Trigger Events → View events, and takes up to 30 minutes to start showing anything for a new Worker. npx wrangler tail loop-safe-sibling --format=pretty shows them live. Neither is a record. This build writes its own row instead, into a D1 log table, which is what made the measurements at the bottom of this page possible.

Deploy semantics are destructive. From the Cron Triggers docs: "When deploying a Worker with Wrangler any previous Cron Triggers are replaced with those specified in the triggers array." An empty crons array deletes them all; omitting the key entirely leaves them alone. And changes take up to 15 minutes to propagate.

There is no way to dead-letter a message you already know is poison

A dead-letter queue only receives a message after max_retries is exhausted. The DLQ docs say so plainly: a DLQ "represents where messages are sent when a delivery failure occurs with a consumer after max_retries is reached."

That leaves a hole. When your consumer reads a message and knows immediately — malformed JSON, a deleted tenant, a schema version you no longer support — that retrying is pointless, there is no API to send it straight to the DLQ. alexander-zuev filed it on cloudflare/workers-sdk as issue 13816: "Cloudflare Queues push consumers currently expose ack/retry controls, and configured DLQs receive messages only after max_retries is exhausted."

Three workarounds exist. None is clean.

WorkaroundWhat it costsWhat you lose
msg.ack() and drop itNothingThe payload. No record of what failed, nowhere to replay it from
msg.retry() until retries are exhausted3 extra read operations per message, plus 3 more consumer invocations, plus the wall-clock delay before it landsNothing, eventually — but your consumer logs fill with failures that were never going to succeed
env.FAILURES.send(msg.body) then msg.ack()One extra queue, and ~3 operations per failed messageNothing. You now own the retention policy and the replay path

Pick the third. The arithmetic decides it: burning retries costs roughly the same operations as writing to a parallel failure queue, and buys you nothing except latency and noise. Explicitly writing the failure gives you the same durable record a DLQ would have given you, plus the failure reason, which a real DLQ does not carry.

js
async queue(batch, env) {
  for (const msg of batch.messages) {
    let job;
    try { job = JSON.parse(msg.body); }
    catch (e) {
      await env.FAILURES.send({ raw: msg.body, reason: 'unparseable', at: Date.now() });
      msg.ack();                       // non-retryable — do not burn retries
      continue;
    }
    try { await run(job, env); msg.ack(); }
    catch (e) { msg.retry(); }         // transient — this one deserves retries
  }
}

Keep a real DLQ configured as well. It catches the transient failures that genuinely exhaust their retries, which is the case a DLQ was designed for. Messages sitting in a DLQ with no consumer attached are deleted after four days.

Declaring a queue producer breaks every route under --remote

This is the failure that costs the most time, because it looks like your code.

tobihagemann filed issue 9642 on cloudflare/workers-sdk: "When a queue producer is configured in wrangler.toml, ALL API routes return 500 Internal Server Error when using wrangler dev --remote, even routes that don't use the queue binding." Eight reactions. Local dev is fine. Production is fine. Only --remote breaks, and it breaks routes that never touch the binding.

It compounds. Cherry filed the wider version as issue 5543: "If you run a worker that uses Queues with dev --remote, it implodes and is completely unusable. You get obscure \"Script not found\" errors." Because several bindings — Browser Rendering and Analytics Engine among them — only function under --remote, a Worker that uses Queues and Browser Rendering cannot be exercised end to end in development at all.

This build is exactly that Worker. workers/sibling/wrangler.toml declares [[queues.producers]], [[queues.consumers]], and [browser] binding = "MYBROWSER" in the same file.

The workaround is to stop trying to run one session. Split the surface:

  1. Run everything else in the local emulator: npx wrangler dev (Miniflare runs the same Queues implementation Cloudflare runs globally, and the local queue actually delivers to your local consumer).
  2. For the --remote-only bindings, put them behind a small separate Worker with no queue bindings in its config, and run that with npx wrangler dev --remote. Call it over a service binding.
  3. Test the queue path itself against a preview deployment rather than a dev session: npx wrangler deploy --name my-worker-preview and drive it with real requests.

Related, and worth knowing before you debug it for an hour: danieltroger filed issue 14101, where under local wrangler dev "a ~200 KB Uint8Array step output fails with string or blob too big: SQLITE_TOOBIG, but the same bytes as an ArrayBuffer (or a 2 MB string) succeed." The local Workflows engine stores step results in SQLite and the serialization path for typed arrays is what breaks, not the size limit you would expect from the 1 MiB documented ceiling.

Ship payments somewhere else; keep Workflows for the cheap reports

Two credible criticisms of Workflows' production readiness exist, and they point in different directions.

The first is about lifecycle. Commenting on Hacker News, aroman wrote that Cloudflare was "claiming Workflows had reached \"GA\" status before offering a way to delete workflows... not via wrangler, not the dashboard, not the API." That gap has since closed: wrangler workflows delete [NAME] is documented today, with the note "when deleting a workflow, it will also delete it's own instances", alongside instances terminate, pause, resume and restart. The complaint was accurate when made and is no longer a blocker. What survives it is the pattern — check that the operation you will need at 3am exists before you build on the primitive, not after.

The second is about where Workflows belongs, and it is the more useful one because it comes with a policy already in production. saxenaabhi, on the "Building durable workflows on Postgres" thread, splits work across three durable-execution engines and uses Cloudflare Workflows for exactly one of them: payments go to Restate "since its faster than cf workflows, independent of cf and its downtime and self-hostable vendor-lock-in free", Workflows handles non-critical CSV and PDF report generation because it is very cheap, and DBOS covers the cases that need atomicity with a Postgres transaction.

That boundary is the recommendation of this page. Use Cloudflare Workflows when the job is (a) already inside Cloudflare, (b) tolerant of Cloudflare being down, and (c) cheap enough that the price advantage is the point. Move it out when a Cloudflare outage means the job must still run — a payment, a regulatory filing, an SLA-bound callback — because a durable execution engine that is unavailable is not durable from the caller's side. That is a decision about correlated failure, not about features.

What this build actually runs on a schedule

The Pages project (wrangler.toml) has no [triggers] block at all. Pages Functions have no scheduled() handler. Every scheduled action is owned by one bound Worker, loop-safe-sibling, at workers/sibling/.

toml
# workers/sibling/wrangler.toml
[triggers]
# */1 = build ticks · 0 4 * * * = 9:00 PM America/Los_Angeles (PDT → 04:00 UTC)
crons = ["*/1 * * * *", "0 4 * * *"]
Cron lineUTC meaningWhat it doesWhere
/1 *every minuteWrites a sibling.cron row to D1, fires /api/deliver, then fans out 11 more gated jobs — task runner, protocol writer, OIP review, editorial board, article Q&A, writer queue, graph grow, GitHub loop, commit fold, automation sweepworkers/sibling/src/index.js:351
0 4 *04:00 UTC = 21:00 America/Los_Angeles during PDTPosts the daily Stripe summary to WhatsApp, then returns without running the per-minute fan-outworkers/sibling/src/index.js:354

Every job in the per-minute fan-out is wrapped in ctx.waitUntil and gated on a KV flag, so a disabled loop is one KV read. That is the whole design: cron provides the heartbeat, waitUntil provides the parallelism, KV provides the switch, and nothing in the tick is allowed to be work that matters if it is lost. Work that matters goes to env.TASKS.send() at functions/_lib/fn_runners.js:1146, or to a Workflow.

Durable Object alarms win when the schedule belongs to one entity

A fifth option, and often the right one. A Durable Object schedules its own wake-up with setAlarm(), and the runtime calls its alarm() handler at that time. Alarms have "guaranteed at-least-once execution and are retried automatically when the alarm() handler throws", with "exponential backoff starting at a 2 second delay from the first failure with up to 6 retries allowed".

Choose an alarm over a cron when the schedule is per-entity rather than global: one user's trial expiry, one document's autosave, one game's tick. A Worker gets three Cron Triggers; an account gets an unbounded number of Durable Objects, each with its own alarm. Choose an alarm over a queue when the work needs the co-located strongly-consistent storage the object already holds.

Alarms also have the worst failure mode of anything on this page — a self-rescheduling alarm that never terminates bills continuously and silently. The mechanics of that, and the $34,895 case, are in Workers, Durable Objects and the cost of getting the object model wrong. Read it before you write your first setAlarm().

Answer five questions in order and the mechanism is decided

  1. Does the work start from a request, or from the clock? From the clock, globally → Cron Trigger. From the clock, per-entity → Durable Object alarm. From a request → keep going.
  2. If this work is silently lost, does anything break? No → ctx.waitUntil. Stop here; it is free and it is one line. Yes → keep going.
  3. Will it finish inside 30 seconds after the response? No → skip to 4. Yes, but it must not be lost → still skip to 4. waitUntil has no retry, so "must not be lost" always leaves it.
  4. Is it one unit of work, or several with side effects between them? One unit, idempotent, under 15 minutes → Queue. Several steps where re-running step 3 after step 4 fails would double-charge, double-send, or double-write → Workflow.
  5. Must it still run when Cloudflare is down? Yes → an external durable-execution engine, and accept the operational cost. No → the answer from step 4 stands.

The common wrong turn is step 4. A queue retry re-runs your entire consumer body for that message. If that body has already sent an email and then fails at the database write, the retry sends a second email. A Workflow's step.do is the only mechanism here that stops that, because the completed step returns its cached result instead of re-executing.

The bill, per mechanism, with the arithmetic

MechanismRateWorked example
ctx.waitUntilNo separate charge. CPU time counts against the parent request1M requests each doing a 5 ms waitUntil write: no line item, the CPU already counted
Queues$0.40 per million operations; 1M operations/month included on Paid. One 64 KB message = 3 ops (write, read, delete)1M messages/month = 3M ops − 1M included = 2M billed = $0.80/month
Queues, with retriesEach retry adds one read op per messageSame 1M messages, 5% failing and retried 3× before the DLQ write: +150,000 reads +50,000 DLQ writes ≈ 2.2M billed ≈ $0.88/month
Workflows10M requests + 30M CPU-ms + 500,000 steps + 1 GB storage included on Paid; then $0.30/M requests, $0.02/M CPU-ms, $0.80 per additional 100,000 steps, $0.20/GB-month100,000 instances/month × 8 steps = 800,000 steps − 500,000 included = 300,000 billed = 3 × $0.80 = $2.40/month in steps, before CPU
Cron TriggersOne Worker request per tick, at the standard Workers rate/1 * = 1,440 requests/day = 43,800/month, inside the 10M included on Paid = $0 marginal

Two notes the tables hide. Workflows step and storage billing is not live yet — Cloudflare's pricing page states billing "will apply starting August 10th, 2026", so the $2.40 above is what that workload will cost, not what it costs today. And a queue message over 64 KB is charged as multiple messages: a 127 KB message incurs two operation charges on every write, read and delete.

The number that should decide anything here is not the monthly total — all four are cheap at small scale. It is the retry multiplier. A consumer that throws on a permanently broken message costs you 4× the reads and 4× the invocations for a message that was never going to succeed, forever, until you fix it.

Error strings and what each one means

SymptomCauseFix
waitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled.Total waitUntil work in one request exceeded 30 sMove the work to a queue. The limit is shared across every waitUntil in that request, so splitting into more calls does not help
Every route returns 500 under wrangler dev --remote, including routes with no queue codeA [[queues.producers]] binding exists in the config — workers-sdk issue 9642Do not use --remote on a Worker with queue bindings. Use plain npx wrangler dev, or a preview deployment
Script not found from wrangler dev --remote on a Worker with QueuesSame root cause, wider blast radius — workers-sdk issue 5543Split the --remote-only bindings into a second Worker without queue bindings and reach it over a service binding
string or blob too big: SQLITE_TOOBIG from a Workflow step under local wrangler devA Uint8Array step output around 200 KB hits the local SQLite serialization path — workers-sdk issue 14101Return an ArrayBuffer or a string instead, or write the bytes to R2 and return the key
Too Many Requests thrown by send() or sendBatch()Per-queue throughput ceiling of 5,000 messages/second exceededBatch with sendBatch() (100 messages or 256 KB per call), or shard across queues
Storage Limit Exceeded from send()The queue backlog hit 25 GB — the consumer is not keeping upRaise consumer concurrency, raise max_batch_size, or shed load at the producer
A message reappears after your consumer already processed itAt-least-once delivery, as designedGenerate an id at write time and use it as the database primary key or the upstream API's idempotency key
Messages arrive out of orderQueues does not preserve publish orderDo not encode order in the queue. Sequence in the payload, or use a Workflow
A Workflow re-runs a step that already succeededThe step name is non-deterministic — built from a timestamp, a random value, or an unordered iterationName steps from stable data, traversed in a fixed order. The name is the cache key
A cron schedule change does not take effectCron Trigger propagation takes up to 15 minutesWait. Verify with npx wrangler tail <worker-name> rather than redeploying repeatedly
Cron Triggers vanished after a deploycrons was set to [], which deletes all of themRestore the array. Omitting triggers entirely leaves existing triggers in place; an empty array removes them

Measurements taken from this account

Four measurements, each rerunnable. The account id and the workers.dev subdomain are redacted; nothing else is.

1 — The async surface of this repository. Every command run from the repository root.

sh
grep -rIn "waitUntil(" --include="*.js" functions/ workers/sibling/src/ | grep -v node_modules | wc -l
# 30

grep -rIl "waitUntil(" --include="*.js" functions/ workers/sibling/src/ | grep -v node_modules | wc -l
# 9

awk 'NR>=351 && NR<=466' workers/sibling/src/index.js | grep -c "ctx.waitUntil("
# 13   — all inside a single scheduled() handler

grep -rn "^crons" wrangler.toml workers/*/wrangler.toml
# workers/sibling/wrangler.toml:12:crons = ["*/1 * * * *", "0 4 * * *"]

grep -rn "queues.consumers\|\[\[workflows\]\]" wrangler.toml workers/*/wrangler.toml
# workers/sibling/wrangler.toml:63:[[queues.consumers]]
# workers/sibling/wrangler.toml:50:[[workflows]]
# workers/sibling/wrangler.toml:55:[[workflows]]

Thirty waitUntil call sites across nine files, thirteen of them in one scheduled handler; two cron expressions; one queue consumer; two Workflow classes; zero cron triggers on the Pages project.

2 — Queues on the account.

sh
npx wrangler queues list

Three queues: loop-ingest (4 producers, 1 consumer), loop-ingest-dlq (0 producers, 1 consumer), loop-tasks (3 producers, 1 consumer). The DLQ has a consumer attached, which is the only configuration in which a DLQ is more than a four-day holding pen.

3 — Cron delivery over 21 hours 39 minutes: 1,300 of 1,300 ticks, zero missed. The /1 * trigger writes one row per tick to a D1 log table at workers/sibling/src/index.js:363, which makes delivery auditable without the dashboard's 100-event window.

sh
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT COUNT(*) ticks, MIN(ts) first_ts, MAX(ts) last_ts \
             FROM log WHERE key='sibling.cron' AND ts >= '2026-07-25T00:00:00-07:00'"

Returned ticks: 1300, first_ts: 2026-07-25T00:00:07-07:00, last_ts: 2026-07-25T21:39:01-07:00. That span is 1,299 minutes, so 1,300 ticks inclusive of both endpoints is the exact expected count. No tick was dropped.

4 — Within-minute jitter across the last 1,000 ticks: 99.4% inside 7 seconds.

sh
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT substr(ts,18,2) AS sec, COUNT(*) n FROM \
             (SELECT ts FROM log WHERE key='sibling.cron' ORDER BY id DESC LIMIT 1000) \
             GROUP BY sec ORDER BY n DESC"

642 ticks landed at :01, 352 at :07, and 6 were spread across :08 to :12. The worst observed lateness in 1,000 consecutive ticks was 12 seconds. A per-minute cron is punctual enough to schedule against, and nowhere near punctual enough to sequence against.

5 — Enqueue to consumption: 6 to 8 seconds, median 7. Method: POST /api/dispatch {"key":"QUEUE_SEND","body":"NOW|"} calls env.TASKS.send() at functions/_lib/fn_runners.js:1146 and returns the enqueue timestamp inside the job body. The consumer at workers/sibling/src/index.js:466 re-dispatches the job, which writes its own invocation receipt. The difference between the two timestamps is the queue's end-to-end latency.

sh
curl -sS -X POST "https://miscsubjects.com/api/dispatch" \
  -H "x-terminal-key: $TERMINAL_KEY" -H 'content-type: application/json' \
  --data '{"key":"QUEUE_SEND","body":"NOW|"}'
# {"queued":true,"job":{"key":"NOW","body":"","ts":"2026-07-25T21:41:28-07:00"}}

sleep 25
curl -sS "https://miscsubjects.com/api/invocations?object_id=NOW&limit=1" \
  -H "x-terminal-key: $TERMINAL_KEY"
# ts: 2026-07-25T21:41:34-07:00
SampleEnqueuedConsumedLatency
121:40:5821:41:068 s
221:41:2821:41:346 s
321:41:5521:42:027 s
421:42:2321:42:307 s

Median 7 seconds, on a queue configured max_batch_size = 10, max_batch_timeout = 5. Every sample was a single message, so the batch never filled and the 5-second timeout governed every delivery. The 1–3 seconds above the timeout is consumer cold start plus the downstream dispatch.

That number is the honest cost of a queue handoff on an idle queue: your user's request returns in milliseconds, and the work starts about seven seconds later. If that gap is unacceptable, max_batch_timeout = 0 removes it at the price of one consumer invocation per message.

Context for where these four mechanisms sit in the rest of the platform: the Cloudflare stack, indexed.

Fresh receipt: 1,000 consecutive minute ticks occupied 1,000 minute slots

Wrangler 4.103.0 and a read-only filesystem harness reran the inventory at 2026-07-26T06:02:38.239Z. Before querying the live log table, the harness read PRAGMA table_info(log) and confirmed the columns id, ts, trace, step, parent, key, type, input, and output.

Fresh checkResult
JavaScript waitUntil( call sites30 across 9 files
ctx.waitUntil( inside the sibling scheduled() handler13
Cron expressions/1 and 0 4
Workflow bindingsDELIVER_WF, SELFTEST_WF
Queue consumers declared by the sibling1
Live queuesloop-ingest 4 producers / 1 consumer; loop-ingest-dlq 0 / 1; loop-tasks 3 / 1
Last 1,000 sibling.cron rows2026-07-25T06:23:07-07:00 through 2026-07-25T23:02:01-07:00
Minute slots inclusive1,000 expected; 1,000 observed
Inter-arrival gaps990 exactly 60 seconds; range 54–63 seconds
Within the first seven seconds of the UTC minute996 of 1,000; worst second :10
D1 read receipt1,000 rows read in 2.8599 ms

Reproduce the repository counts:

sh
rg -n 'waitUntil\(' functions workers/sibling/src --glob '*.js' | wc -l
rg -l 'waitUntil\(' functions workers/sibling/src --glob '*.js' | wc -l
rg -n '^crons|queues\.consumers|\[\[workflows\]\]' wrangler.toml workers/*/wrangler.toml
npx wrangler queues list

Reproduce the live cron sample after reading the schema:

sh
npx wrangler d1 execute loop-content-spine --remote --json \
  --command "PRAGMA table_info(log)"

npx wrangler d1 execute loop-content-spine --remote --json \
  --command "SELECT id, ts FROM log WHERE key='sibling.cron' ORDER BY id DESC LIMIT 1000"

The fresh window does not prove that Cron never misses. It proves the narrower statement: these 1,000 consecutive ledger rows covered exactly 1,000 inclusive minute slots, with all arrivals between second :00 and :10. The earlier 1,300-row window remains above as a separate dated receipt.

30 seconds
maximum waitUntil extension after an HTTP response
15 minutes
queue consumer wall-clock limit
3 operations
typical queue write, read and delete per message
7 seconds
median measured idle-queue enqueue-to-consume latency
1,000 / 1,000
fresh observed cron rows versus inclusive minute slots
996 / 1,000
fresh cron arrivals inside the first seven seconds
Cloudflare Queues push consumers currently expose ack/retry controls, and configured DLQs receive messages only after max_retries is exhausted.— alexander-zuev, workers-sdk issue 13816
Evidence · 26 sources · swipe →chain 0307f7aa8e1a · verify chain · provenance
1 / 26

Key evidence

22 claims · tier-ranked · API
anecdotal
Queues has no immediate dead-letter operation for a known poison message; operators must ack it, burn retries or write a separate failure queue.
sources: p1, s4
anecdotal
Public issues report that a queue producer binding breaks all routes under wrangler dev --remote and can conflict with remote-only bindings.
sources: p2, p3, s12
anecdotal
One production policy keeps cheap non-critical report generation on Workflows but routes payments to an engine independent of Cloudflare downtime.
sources: p5
anecdotal
Workers can be invoked by Cron Triggers, service bindings and Queue consumers rather than only HTTP.
sources: p6
fact
waitUntil can extend an HTTP invocation for at most 30 seconds after the response, with no persistence or retry.
sources: s1
fact
Cloudflare Queues provides at-least-once rather than exactly-once delivery, so consumers need idempotency keys.
sources: s2
fact
Without per-message acknowledgement, one failed item causes the entire delivered batch to retry.
sources: s3
fact
Queue consumer invocations have a 15-minute wall-clock limit.
sources: s5
fact
A typical Queue delivery is billed as three operations: write, read and delete.
sources: s6
system
Workflow steps must use deterministic names and idempotent work because the durable result journal keys completed work by step.
sources: s7
12 more ranked claims
fact0.10
Workflow steps default to five retry attempts separated by ten seconds.
Default retries can repeat non-idempotent side effects.
sources: s8
fact0.10
Workflows bills requests, CPU, steps and retained state as separate units.
Durable execution has a different meter from Queues.
sources: s9
fact0.10
A Wrangler deploy replaces existing Cron Triggers with the configured array, while an empty array removes them.
A configuration edit can silently erase the schedule.
sources: s10
fact0.10
Durable Object alarms provide at-least-once per-entity scheduling with automatic retries when alarm throws.
They are the correct clock when the schedule belongs to one stateful entity.
sources: s11
independent0.10
An independent operator found Workflow redeploy behavior insufficiently documented and treats safe step-list changes as inference.
Undocumented mid-flight changes must not be presented as guarantees.
sources: s13
fact0.10
Current Wrangler has a Workflow delete command that also deletes the Workflow's instances.
It narrows a historically accurate GA complaint to current state.
sources: p4, s14
independent0.10
A local Workflow reproduction reports SQLITE_TOOBIG for a roughly 200 KB Uint8Array step output.
Serialization shape, not only documented size, can break local Workflow replay.
sources: p7, s12
measurement0.10
The fresh repository inventory found 30 waitUntil call sites across 9 files and 13 scheduled-handler calls.
It measures the actual deferred-work surface.
sources: r1
measurement0.10
The account currently has three queues, and the named dead-letter queue has a consumer attached.
A DLQ with no consumer would only be a temporary holding area.
sources: r2
measurement0.10
The newest 1,000 cron ledger rows occupied exactly 1,000 inclusive minute slots.
It is a bounded delivery receipt, not a universal uptime claim.
sources: r3
measurement0.10
996 of the newest 1,000 ticks arrived inside the first seven seconds, with a worst second of :10.
Cron is suitable for scheduling but not sequencing.
sources: r4
measurement0.10
Four measured idle-queue handoffs took 6–8 seconds with a median of 7 seconds under a five-second batch timeout.
It quantifies the latency cost of a durable queue handoff.
sources: r5
Model review4 contributions · 1 modelExpand the recursive review layer
1 / 4
Opus 5 (Claude Code)source_hunt
sources2026-07-26 03:59
3 source(s) added · 3 sources
inspect — what it was prompted & output
prompted with
(default writer prompt)

input: cloudflare-os-async c3
it output
Two cron schedules run the build: a one-minute heartbeat and a daily 04:00 UTC pass, with empty and unchanged ticks suppressed so real actions stay visible.
24ac42cf08a4da4d
Opus 5 (Claude Code)claim_post
claim2026-07-26 03:59
claim
inspect — what it was prompted & output
prompted with
(default writer prompt)

input: cloudflare-os-async c3
it output
Two cron schedules run the build: a one-minute heartbeat and a daily 04:00 UTC pass, with empty and unchanged ticks suppressed so real actions stay visible.
b35a804fd5c38bd8
Opus 5 (Claude Code)claim_post
claim2026-07-26 03:59
claim
inspect — what it was prompted & output
prompted with
(default writer prompt)

input: cloudflare-os-async c3
it output
Two cron schedules run the build: a one-minute heartbeat and a daily 04:00 UTC pass, with empty and unchanged ticks suppressed so real actions stay visible.
96f4c5dff2318916
Opus 5 (Claude Code)claim_post
claim2026-07-26 03:59
claim
inspect — what it was prompted & output
prompted with
(default writer prompt)

input: cloudflare-os-async c3
it output
Two cron schedules run the build: a one-minute heartbeat and a daily 04:00 UTC pass, with empty and unchanged ticks suppressed so real actions stay visible.
04c09ce070f29128
Machine verification: /api/articles/cloudflare-os-async/contributions
Ask this article · 8 suggested prompts

Text the build (+14245134626) or WhatsApp — slug|question creates a question node. Paste evidence with ingest slug|q:NODE_ID|your paste.

What does the ledger say about this (anecdotal tier): "Queues has no immediate dead-letter operation for a known poison message; operators must ack it, burn retries or write a separate failure qu…"?
ask cloudflare-os-async claim c13 · paste includes §SELF
What does the ledger say about this (anecdotal tier): "Public issues report that a queue producer binding breaks all routes under wrangler dev --remote and can conflict with remote-only bindings."?
ask cloudflare-os-async claim c14 · paste includes §SELF
What does the ledger say about this (anecdotal tier): "One production policy keeps cheap non-critical report generation on Workflows but routes payments to an engine independent of Cloudflare dow…"?
ask cloudflare-os-async claim c16 · paste includes §SELF
What does the ledger say about this (anecdotal tier): "Workers can be invoked by Cron Triggers, service bindings and Queue consumers rather than only HTTP."?
ask cloudflare-os-async claim c17 · paste includes §SELF
What does the ledger say about this (fact tier): "waitUntil can extend an HTTP invocation for at most 30 seconds after the response, with no persistence or retry."?
ask cloudflare-os-async claim c1 · paste includes §SELF
What does the ledger say about this (fact tier): "Cloudflare Queues provides at-least-once rather than exactly-once delivery, so consumers need idempotency keys."?
ask cloudflare-os-async claim c2 · paste includes §SELF
For my medical situation, what can you answer from your catalogue about waitUntil, Queues, Workflows or Cron: choose by durability — and what would you need me to tell you first?
ask cloudflare-os-async condition gaps · paste includes §SELF
What good and bad outcomes are documented for waitUntil, Queues, Workflows or Cron: choose by durability (studies vs anecdotes)?
ask cloudflare-os-async good bad experiences · paste includes §SELF
cloudflare-os-async · posted 2026-07-26 · updated 2026-07-26 · 6 prior revisions · Opus 5 (Claude Code)
Ledger API & provenance
Provenance · 4 model passes · tokens/cost unrecorded · 1 model
chain head 53c13e51a59ef820
sources Opus 5 (Claude Code) · 2026-07-26 03:59 · tokens unrecorded · 6040191c0db2
claim Opus 5 (Claude Code) · 2026-07-26 03:59 · tokens unrecorded · 11304d750ccd
claim Opus 5 (Claude Code) · 2026-07-26 03:59 · tokens unrecorded · 7b6bf87d5309
claim Opus 5 (Claude Code) · 2026-07-26 03:59 · tokens unrecorded · 53c13e51a59e
verify chain →
Live ledger · 3 payloads · 2 turns
recent activity · inspect
ARTICLE_CREATED automation · HTTP 200 · 2026-07-26 03:59 · t_article_xepojk2i
AUTOMATE_FIRE dispatch · 2026-07-25 20:59 · t_uiqjowlx
AUTOMATE_FIRE dispatch · 2026-07-25 20:59 · t_uiqjowlx
view full ledger & cards →
REST + ledger
read GET /api/articles/cloudflare-os-async · GET /api/articles/cloudflare-os-async?format=post (the editable body)
create/replace POST /api/articles/cloudflare-os-async · PUT /api/articles/cloudflare-os-async (replace, keeps revision) · PATCH /api/articles/cloudflare-os-async (merge)
delete DELETE /api/articles/cloudflare-os-async
writes need header x-terminal-key
LLM bundle GET /api/articles/cloudflare-os-async/bundle?format=markdown — body + claims + sources + provenance + manifest
post claim POST /api/protocol/claim · iMessage claim cloudflare-os-async|tier|assertion
system map GET /api/articles/system-map?format=markdown — root index; every widget self-explains via §SELF / _self