Flow Control
Per-workflow concurrency, throttle, rate-limit, debounce, batch, priority, singleton, and idempotency.
Flow control shapes how a workflow runs under load. Each control is an optional, flat field on the
workflow definition, alongside retry. A workflow with no flow config runs unshaped - every control is
opt-in and off by default.
defineWorkflow({
name: "sync.account",
concurrency: { limit: 5, key: "accountId" },
handler: async (ctx) => {
await ctx.step.run("sync", () => syncAccount(ctx.event.data.accountId));
},
});At a glance
| Control | Purpose | Acts at | On overflow |
|---|---|---|---|
concurrency | Cap simultaneous runs | execution (lease) | wait + retry |
throttle | Cap start rate, smoothly | admission | delay |
rateLimit | Cap start rate, shedding | admission | drop |
debounce | Collapse a burst to its last event | admission | coalesce |
batch | Fold many events into one run | admission | collect |
priority | Jump the shared queue | admission | re-order |
singleton | One run at a time per key | admission | skip or cancel |
idempotency | One run per key within a window | admission | drop (deduped) |
Keys
Most controls take an optional key: a dotted path into the event data that scopes the control to a
value. key: "accountId" gives each account its own independent limit; key: "user.id" reads a nested
field. Keys are field paths, not expressions. An omitted key - or a missing / non-scalar field - scopes
the control to the whole workflow.
Concurrency
Caps how many runs execute at once in a scope. A slot is a held execution lease, so a run that is sleeping or awaiting an event has released its lease and does not consume one. The count is taken across every engine on the shared database, so the limit is global, not per-process. Over-limit runs are not dropped - they wait and retry as slots free, preserving order.
concurrency: { limit: 5, key: "accountId" }| Property | Type | Default | Description |
|---|---|---|---|
limit | number | required | Max runs executing simultaneously in the scope. |
key | string | whole workflow | Event-data path; each value gets an independent limit. |
Throttle
Bounds how often runs start, smoothing bursts by spreading overflow into the future - one start
every perMs / limit. No run is lost; excess runs begin later.
throttle: { limit: 100, perMs: 60_000, key: "tenant" }| Property | Type | Default | Description |
|---|---|---|---|
limit | number | required | Max starts per window. |
perMs | number (ms) | required | Window length. |
key | string | whole workflow | Event-data path; each value gets its own rate. |
Rate limit
Same window as throttle, opposite action: instead of delaying overflow it drops it. Up to limit
runs start per perMs; the rest are shed and the event response reports dropped: true. Use it for
abuse protection where shedding beats queueing.
rateLimit: { limit: 1000, perMs: 60_000, key: "ip" }| Property | Type | Default | Description |
|---|---|---|---|
limit | number | required | Max starts admitted per window. |
perMs | number (ms) | required | Window length. |
key | string | whole workflow | Event-data path; each value gets its own rate. |
Throttle and rate limit share one rate primitive (GCRA). Throttle delays the overflow; rate limit drops it.
Debounce
Coalesces a burst of events into a single run that fires after periodMs of quiet. Each new event
slides the deadline forward and replaces the payload, so only the last event in a quiet-bounded
burst runs.
debounce: { periodMs: 5_000, key: "documentId" }| Property | Type | Default | Description |
|---|---|---|---|
periodMs | number (ms) | required | Quiet gap after the last event before the run fires. |
key | string | whole workflow | Event-data path; each value debounces independently. |
Batch
Collects events into one run, flushing when the buffer hits maxSize or timeoutMs elapses,
whichever comes first. The run receives the events as ctx.events; ctx.event is the first of them.
defineWorkflow({
name: "index.documents",
batch: { maxSize: 100, timeoutMs: 5_000, key: "index" },
handler: async (ctx) => {
for (const e of ctx.events ?? []) {
await ctx.step.run(e.data.id, () => index(e.data));
}
},
});| Property | Type | Default | Description |
|---|---|---|---|
maxSize | number | required | Flush once this many events are buffered. |
timeoutMs | number (ms) | required | Flush this long after the first buffered event. |
key | string | whole workflow | Event-data path; each value batches separately. |
Priority
Shifts a workflow's runs earlier in the shared queue by shiftMs, so they dequeue ahead of other
workflows competing for the same engines.
priority: { shiftMs: 60_000 }| Property | Type | Default | Description |
|---|---|---|---|
shiftMs | number (ms) | required | Treat runs as if enqueued this many ms earlier. |
Singleton
Allows at most one non-terminal run per key.
singleton: { key: "accountId", mode: "skip" }| Property | Type | Default | Description |
|---|---|---|---|
key | string | whole workflow | One concurrent run per value. |
mode | "skip" | "cancel" | "cancel" | On collision: skip drops the new trigger (response reports skipped: true); cancel cancels the running run and starts the new one. |
Idempotency
Suppresses a second run of this workflow for the same derived key within a time window. The first
matching event starts a run; a later event whose key resolves to the same value inside the window is
dropped for this workflow (the response reports deduped: true with no runId). The event is still
recorded and still wakes waitForEvent waiters - only the duplicate run is suppressed.
idempotency: { key: "orderId", periodMs: 86_400_000 } // at most one run per orderId per 24h| Property | Type | Default | Description |
|---|---|---|---|
key | string | whole workflow | Event-data path; one run per value within the window. An omitted key means one run per window for the whole workflow. |
periodMs | number (ms) | 86_400_000 (24h) | How long a key stays claimed before it can run again. |
This is run-level dedupe keyed off the event payload. To dedupe a whole event regardless of which
workflows it matches - the usual safety net for an at-least-once caller retrying POST /events - send a
dedupeId on the event instead (see the wire protocol); a repeat of
that id within 24h is dropped before any fan-out.
Global correctness
Every flow-control limit is enforced against shared state, so concurrency, throttle, and rate-limit counts hold exactly across your whole workspace - never approximated per instance.
Observing flow control
The configured controls and their live runtime state are visible without extra setup:
- The console marks every flow-controlled workflow with a badge in the Workflows list, and the workflow detail drawer has a Flow control section: each configured control with its policy, plus live counters where they apply (in-flight vs the concurrency limit, queued runs, and the number of events currently coalescing in a debounce window or buffered in a batch). The Overview page rolls the same numbers up namespace-wide (queue depth, in-flight, debounce/batch pending).
GET /flow-statereturns the live buffer state directly: the debounce and batch backlogs per workflow (?app=and?workflow=narrow the scope). In-flight and queued counts come fromGET /runs/stats. A workflow's configured controls ride theflowControlfield ofGET /workflows.