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

ControlPurposeActs atOn overflow
concurrencyCap simultaneous runsexecution (lease)wait + retry
throttleCap start rate, smoothlyadmissiondelay
rateLimitCap start rate, sheddingadmissiondrop
debounceCollapse a burst to its last eventadmissioncoalesce
batchFold many events into one runadmissioncollect
priorityJump the shared queueadmissionre-order
singletonOne run at a time per keyadmissionskip or cancel
idempotencyOne run per key within a windowadmissiondrop (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" }
PropertyTypeDefaultDescription
limitnumberrequiredMax runs executing simultaneously in the scope.
keystringwhole workflowEvent-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" }
PropertyTypeDefaultDescription
limitnumberrequiredMax starts per window.
perMsnumber (ms)requiredWindow length.
keystringwhole workflowEvent-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" }
PropertyTypeDefaultDescription
limitnumberrequiredMax starts admitted per window.
perMsnumber (ms)requiredWindow length.
keystringwhole workflowEvent-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" }
PropertyTypeDefaultDescription
periodMsnumber (ms)requiredQuiet gap after the last event before the run fires.
keystringwhole workflowEvent-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));
    }
  },
});
PropertyTypeDefaultDescription
maxSizenumberrequiredFlush once this many events are buffered.
timeoutMsnumber (ms)requiredFlush this long after the first buffered event.
keystringwhole workflowEvent-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 }
PropertyTypeDefaultDescription
shiftMsnumber (ms)requiredTreat runs as if enqueued this many ms earlier.

Singleton

Allows at most one non-terminal run per key.

singleton: { key: "accountId", mode: "skip" }
PropertyTypeDefaultDescription
keystringwhole workflowOne 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
PropertyTypeDefaultDescription
keystringwhole workflowEvent-data path; one run per value within the window. An omitted key means one run per window for the whole workflow.
periodMsnumber (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-state returns the live buffer state directly: the debounce and batch backlogs per workflow (?app= and ?workflow= narrow the scope). In-flight and queued counts come from GET /runs/stats. A workflow's configured controls ride the flowControl field of GET /workflows.

On this page