Skip to content
Ironflow is pre-1.0: APIs can change between releases. Pin a version. What is ready →

Throttle

A throttle limits how fast a function starts new runs. Up to limit runs start in any rolling period; a run over the limit waits in the queue until a slot frees. Nothing is dropped and nothing fails: a throttle only delays the start.

Use it when:

  • You call a rate-limited API (a vendor that allows 100 requests a minute) and want the start rate, not the concurrency, to stay under its ceiling.
  • One tenant’s burst must not flood a shared downstream. Give the throttle a key and each tenant gets its own budget.
  • You want to smooth a spike of events into a steady stream without losing any.

A throttle is soft. It paces starts; it does not cap how many runs are in flight (that is concurrency) and it does not coalesce events (that is debounce).

Register the function with a throttle setting:

FieldTypeDescription
limitinteger, at least 1Runs allowed to start per period.
period_msinteger, at least 1000Length of the rolling window, in milliseconds.
keystring, optionalDot-notation path into the triggering event’s payload. Each distinct value gets its own budget. Omit it for one budget shared by the whole function.

The registration request carries it as throttle:

{
"id": "sync-to-vendor",
"triggers": [{ "event": "customer.updated" }],
"throttle": { "limit": 100, "period_ms": 60000, "key": "customerId" }
}

That allows 100 runs of sync-to-vendor to start per minute for each customerId.

The SDKs take the same setting on the function definition:

// Node
createFunction(
{
id: "sync-to-vendor",
triggers: [{ event: "customer.updated" }],
throttle: { limit: 100, periodMs: 60_000, key: "customerId" },
},
async ({ event, step }) => { /* ... */ },
);
// Go
ironflow.CreateFunction(ironflow.FunctionConfig{
ID: "sync-to-vendor",
Triggers: []ironflow.Trigger{{Event: "customer.updated"}},
Throttle: &ironflow.ThrottleConfig{Limit: 100, Period: time.Minute, Key: "customerId"},
}, handler)
# Python
@function(
id="sync-to-vendor",
triggers=[{"event": "customer.updated"}],
throttle={"limit": 100, "period": "1m", "key": "customerId"},
)
async def sync_to_vendor(ctx): ...

The window is rolling, not a fixed clock minute. A run may start when fewer than limit runs of the same key have started in the last period. A burst of limit runs starts at once; the next one starts when the oldest start ages out of the window.

limit 2 per 10s, five runs arrive at t=0:
t=0 t=10 t=20
│ A B │ C D │ E
▼ ▼ ▼ ▼ ▼
start start start

A run over the limit stays queued in waiting_for_capacity. Its eligible_at moves to the moment a slot frees, so it is picked up then without waiting for a periodic sweep. Runs of one key keep their arrival order: the third run takes the first freed slot, the fourth the next. A new run never starts ahead of an older run of the same key that is still waiting, even when the window has room at that instant. A held or cancelled run is not ahead of anyone: it takes no slot and does not push the runs behind it later. A slot given back by a run that never started (cancelled after it was admitted, or a reservation that lapsed) goes to the oldest run still waiting, ahead of any that arrive later.

Very deep backlogs are scheduled in stages. Slots are computed for about a thousand waiting runs per key at a time; runs further back are given the last computed slot and are scheduled again from a shorter queue when it arrives. The limit holds throughout, and no run waits longer than its exact slot.

The count is held in the database and checked in the same transaction that reserves the run, so it is exact across every engine node and in both push and pull mode. SQLite (development) rounds a wait up to the next whole second.

Only the first start of a new run counts. A retry, a resume after a sleep or waitForEvent, and a recovery after a lease expiry are not throttled, so a throttle can delay a run’s beginning but never stalls it halfway through. A run that retries three times used one slot, not four. A run that was held before it ever started is still waiting to start, so resuming it goes through the throttle.

A slot is given back when the run does not start after all: the reservation expired, or the run was cancelled, held or replaced between being picked and starting. A cancelled run never takes a slot in the first place.

key resolves like a concurrency key. A bare name is looked up at the top of the event payload and then under data; a dotted path (org.id) is followed as written. The value must be a string, number or boolean. A run whose key does not resolve to one fails at once with THROTTLE_KEY_UNRESOLVED and is not retried, the same as CONCURRENCY_KEY_UNRESOLVED.

The key value is stored only as an HMAC digest, never in the clear.

The limit and period are read when a run is about to start, so a change applies to every run that starts after it, including runs already waiting. Updating the function releases the runs the throttle was holding back to be re-checked at once: raise the limit and the backlog starts draining under the new rate immediately; remove the throttle and it all starts. A run delayed for another reason, such as a start delay, keeps its time.

ironflow_throttle_delayed_total{environment_id, function_id} counts runs whose start was pushed back, once per run: a run that comes due and is pushed back again is not counted again. A steadily rising value means the throttle is the bottleneck for that function. See Observability.

Waiting runs show waiting_for_capacity, as they do behind a full concurrency lane or an open circuit breaker.

SettingLimitsOver the limit
ThrottleStarts per periodThe run waits, then starts
ConcurrencyRuns in flight at onceThe run waits for a slot
DebounceRuns per burst of eventsEvents collapse into one run

A function can have more than one. A run must clear both the throttle and its concurrency lane to start.