Skip to content

Parallel steps with Task.WhenAll

Steps run in parallel when the function starts them before awaiting them. There is no special API: start the tasks, then await them with Task.WhenAll or Task.WhenAny.

var user = context.Event.Data;
var profile = context.Step.RunAsync("load-profile", ct => profiles.LoadAsync(user.UserId, ct));
var orders = context.Step.RunAsync("load-orders", ct => orderHistory.LoadAsync(user.UserId, ct));
await Task.WhenAll(profile, orders);
context.Logger.LogInformation("{Name} has {Count} orders", (await profile).Name, (await orders).Count);

When a call finds more than one new step, the SDK runs none of them. It answers with a StepPlanned op for each, and Inngest runs every planned step in a call of its own. Those calls can run at the same time, on one instance of the app or on several. Once the steps have finished, Inngest calls the function again; the results are memoized and the function continues after Task.WhenAll.

Call 1 reaches two new steps

load-profile and load-orders, before Task.WhenAll

206 · two planned steps

Call 2 runs

load-profile

Call 3 runs

load-orders

once both are saved

Call 4 continues after Task.WhenAll

both steps return their saved results

When a call reaches two new steps at once, the SDK runs neither. It answers 206 with both as planned steps, Inngest runs each in a call of its own, possibly at the same time and on different instances, and calls the function again once both results are saved.

How it works shows a parallel section call by call, including the planned steps and the calls that run them.

When a call finds a single new step, the SDK runs it right away, as it does for sequential steps, unless Inngest has asked it not to while a parallel section is still running.

Start one step per item and await them together. Give each step an id of its own:

var invoices = await context.Step.RunAsync("load-invoices",
ct => billing.GetOpenInvoicesAsync(customerId, ct));
var paymentIds = await Task.WhenAll(invoices.Select(invoice =>
context.Step.RunAsync($"charge-{invoice.Id}", ct => billing.ChargeAsync(invoice.Id, ct))));

Every step counts toward the function’s concurrency limit, so a limit of 5 runs at most five of these charges at once, across all runs of the function. For hundreds of items, consider invoking a function per item, or sending an event per item, so that each item is a run of its own with its own retries.

MapAsync does this for a list, with ids for the steps, a limit on how many run at once, and a choice of stopping at the first failure or collecting every outcome, and its chunked form sends lists that are too long for one run to child runs. See Fan-out over a list.

Task.WhenAny works too. It completes with the step that finished first:

var primary = context.Step.RunAsync("quote-primary", ct => primaryCarrier.QuoteAsync(shipment, ct));
var backup = context.Step.RunAsync("quote-backup", ct => backupCarrier.QuoteAsync(shipment, ct));
var quote = await await Task.WhenAny(primary, backup);

Every planned step still runs to the end; the losing quote is recorded and simply not used. By default Inngest calls the function again only after all the planned steps have finished, so a race picks the first result but does not return it any sooner. The SDK replays results in the order they finished, so the same step wins on every later call.

To go on as soon as the first step finishes, run the race in race mode. Every step started inside ParallelAsync tells Inngest to call the function again after each of them finishes:

var quote = await await context.Step.ParallelAsync(ParallelMode.Race, () => Task.WhenAny(
context.Step.RunAsync("quote-primary", ct => primaryCarrier.QuoteAsync(shipment, ct)),
context.Step.RunAsync("quote-backup", ct => backupCarrier.QuoteAsync(shipment, ct))));

The losing step still runs to the end. The first step after the race runs right away, but the steps after that wait for the losing step to finish, unless they run in race mode too.

  • Different ids in different branches. Two parallel branches that reuse an id share its numbering, and which branch gets which result can depend on timing. The SDK logs a warning when it sees that.
  • Do not block on a step. .Result, .Wait() and GetAwaiter().GetResult() on a step’s task never return: the SDK hands out results on the function’s own scheduler, which a blocked thread holds up.
  • Leaving the scheduler. Code that uses ConfigureAwait(false) or Task.Run inside a function still works, but steps started from other threads may be reported one at a time instead of all at once.
  • Unawaited steps still run. A step that is started and never awaited is reported to Inngest like the others. If the function returns while such steps are new, the SDK reports the steps instead of the output and returns the output on a later call.
  • Scoped services. Each step started together runs in a call of its own, with a dependency injection scope of its own, so parallel steps do not share a DbContext that the function injects. Within one call the function’s code and its steps do share it; Scoped steps gives a step a scope of its own.
  • Inngest’s limits. A run has 1,000 steps by default, parallel ones included, and fails once that many have completed. See the limits page.

The Inngest docs describe parallel steps for the TypeScript SDK, which uses Promise.all where .NET uses Task.WhenAll.

Fan-out with invoke and the Connect worker run steps in parallel.

inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.