Skip to main content
When you trigger a task, the run waits in a queue and runs start in trigger order as capacity allows. Concurrency is what decides how much capacity there is. By default, concurrency is only limited by your environment concurrency limit. If you need more control (for example, to limit concurrency or share limits across multiple tasks), you can set a concurrency limit on the task, or declare a named limit and share it, as described below. Controlling concurrency is useful when you have a task that can’t be run concurrently, or when you want to limit the number of runs to avoid overloading a resource. It’s important to note that only actively executing runs count towards concurrency limits. Runs that are delayed or waiting in a queue do not consume concurrency slots until they begin execution.

Use cases

Default concurrency

By default, all tasks have an unbounded concurrency limit, limited only by the overall concurrency limits of your environment.
Your environment has a base concurrency limit and a burstable limit (default burst factor of 2.0x the base limit). Individual tasks and limits are capped by the base concurrency limit, not the burstable limit. For example, if your base limit is 10, your environment can burst up to 20 concurrent runs, but any single limit can allow at most 10 concurrent runs. If you’re a paying customer you can request higher burst limits by contacting us.

Setting task concurrency

Set the concurrency option on a task to limit how many of its runs execute at once. { total: n } caps the task outright:
/trigger/one-at-a-time.ts
This is useful if you need to control access to a shared resource, like a database or an API that has rate limits. There are two ways to bound a task, and you can combine them:
  • total caps every run of the task together, whether or not runs use a concurrencyKey.
  • perKey caps each concurrencyKey pool separately; runs triggered without a key share one pool.
/trigger/per-user.ts
The queue: {"{ concurrencyLimit: n }"} option keeps working but is deprecated in favor of concurrency. Its single number means “per key when runs pass a concurrencyKey, whole queue when they don’t” — the concurrency shape says which you mean explicitly.

Sharing a limit between tasks

Declare a named limit with concurrencyLimit() and put it in each task’s concurrency. Every task holding the limit draws from the same pools:
/trigger/limits.ts
A task’s concurrency takes a single item or an array: at most one inline shape (which caps that task alone) plus up to two named limits. A run starts only when every limit it holds has capacity, and it occupies a slot in each while it executes:
/trigger/summarize.ts
Names you declare with concurrencyLimit() are 1-122 characters using only letters, numbers, underscores and hyphens.

Setting limits when you trigger a run

The trigger-time concurrency option takes limit names and replaces the task’s declared named limits for that run. The task’s inline limit always applies:
app/api/report/route.ts
Pass an empty array to run with only the task’s inline limit.

Concurrency keys and per-tenant limits

If you’re building an application where you want to run tasks for your users, you might want a separate limit for each of your users (or orgs, projects, etc.). You can do this by passing a concurrencyKey when you trigger. Each unique key value gets its own pool under every perKey bound the run holds:
app/api/report/route.ts

Per-key and total limits together

perKey on its own lets total concurrency grow with the number of active keys: ten active users under perKey: 5 can run 50 at once. Add total to bound everything as a group. Each key still gets at most perKey, and all runs together — keyed or not — never exceed total:
/trigger/per-user-capped.ts

A per-tenant cap across multiple tasks

A named limit’s perKey bound follows each run’s own concurrencyKey, so one declaration caps each tenant across every task holding the limit:
/trigger/webhooks.ts
app/api/webhook/route.ts

A global cap for a shared resource

To cap something global, like total traffic to an external API, across many tasks and all tenants: declare a limit with only a total and share it. It counts every run holding it, whether or not the run has a concurrencyKey:
/trigger/sync.ts
Runs with and without a concurrencyKey share the same total, so this works even when only some of your triggers have a natural key.

Concurrency and subtasks

When you trigger a task that has subtasks, the subtasks will not inherit the parent’s limits. Unless otherwise specified, subtasks run under their own task’s configuration:
/trigger/subtasks.ts

Waits and concurrency

With our task checkpoint system, tasks can wait at various waitpoints (like waiting for subtasks to complete, delays, or external events). The way this system interacts with the concurrency system is important to understand. Concurrency is only released when a run reaches a waitpoint and is checkpointed. When a run is checkpointed, it transitions to the WAITING state and releases its concurrency slots back to every limit it holds and the environment, allowing other runs to execute or resume. This means that:
  • Only actively executing runs count towards concurrency limits
  • Runs in the WAITING state (checkpointed at waitpoints) do not consume concurrency slots
  • You can have more runs in the WAITING state than a limit allows to execute
  • When a waiting run resumes (e.g., when a subtask completes), it must re-acquire its slots
For example, if a task has concurrency: { total: 1 }:
  • You can only have exactly 1 run executing at a time
  • You may have multiple runs in the WAITING state for that task
  • When the executing run reaches a waitpoint and checkpoints, it releases its slot
  • The next queued run can then begin execution

Short time-based waits keep their slot

Checkpointing takes time, so a run doesn’t checkpoint the moment it reaches a waitpoint. For wait.for() and wait.until() it happens 60 seconds into the wait, so anything shorter stays EXECUTING and holds its slots for the whole wait. If you’re polling in a loop, use an interval comfortably above 60 seconds so the slots are actually released between polls.

Waiting for a subtask

When a parent task triggers and waits for a subtask, the parent task will checkpoint and release its concurrency slots once it reaches the wait point. This prevents environment deadlocks where all concurrency slots would be occupied by waiting tasks.
/trigger/waiting.ts
When the parent task reaches the triggerAndWait call, it checkpoints and transitions to the WAITING state, releasing its slots. Once the subtask completes, the parent task will resume and re-acquire them.

Managing concurrency limits with the SDK

The concurrencyLimits namespace manages your named limits at runtime (anonymous inline limits appear under derived task/<task-id> names):

Listing limits

Retrieving a limit

Retrieve a limit by its name to see its bounds and live counts:
The limit object contains each bound plus live counts:

Overriding a limit

Overrides change only the fields you pass; the declared values are kept and restored by reset:
Overrides survive deploys: redeploying your code keeps an active override until you reset it.

Pausing a limit

Pause a limit to stop every run holding it from being dequeued; runs that are already executing continue to completion. The configured bounds are kept, and resuming restores them:
You can also pause and resume a limit from the Concurrency page in the dashboard, exactly like a queue. Overriding total to 0 also blocks every run holding the limit, but pause is the first-class way to do this and leaves your configured bounds untouched.