Skip to content

Durable steps: RunAsync and step ids

A step is a piece of a function that Inngest records. context.Step.RunAsync runs its handler once and saves the result. Inngest runs a function by calling it again and again, and on every later call the step returns the saved result instead of running again. If the handler throws, only that step is retried.

var order = context.Event.Data;
var paymentId = await context.Step.RunAsync("charge",
ct => payments.ChargeAsync(order.CustomerId, order.Total, ct));
await context.Step.RunAsync("mark-paid",
ct => orders.MarkPaidAsync(order.OrderId, paymentId, ct));

If mark-paid fails, Inngest retries it, and the function’s code runs again from the top: charge returns the saved payment id without charging the card a second time. How it works shows this call by call, and the Inngest docs cover steps in general.

Call 1runs charge

charge206

Call 2replays charge, runs mark-paid

chargemark-paid206

Call 3replays both and returns

chargemark-paidreturn200
  • runs the handler
  • returns the saved result
Inngest calls the function once for each step. Every call runs the code from the top; steps that already ran return their saved result at once, so charge runs a single time even though three calls pass it.
Method What it does More
RunAsync(id, handler) Runs code once, saves its result, retries it on failure. This page
SleepAsync(id, duration) Pauses the run for a duration. Sleep and wait for events
SleepUntilAsync(id, time) Pauses the run until a point in time. Sleep and wait for events
WaitForEventAsync<T>(id, options) Waits for a matching event, or returns null at the timeout. Sleep and wait for events
InvokeAsync<T>(id, options) Runs another function and returns its result. Invoking functions
SendEventAsync(id, events) Sends events exactly once per run. Sending events
AIWrapAsync(id, input, handler) Runs your own model call as a step that the Inngest UI shows as one. Calls to AI models, AI chat clients
RunScopedAsync(id, handler) Runs code once like RunAsync, in a dependency injection scope of its own, so a DbContext is new for every step. Scoped steps
PublishAsync(id, topic, data) Publishes a realtime message once. Realtime

All of them are on IStepTools, which is an interface so that functions can be unit tested with a fake.

Inngest stores each result under its step id, so ids have two rules:

  • Unique within the run. Two different steps with the same id would share one result.
  • Stable. The same step must get the same id on every call, and in every version of the code that runs while a run is in flight. Do not build ids from the time, random values or anything else that changes between calls.

Reusing an id in a loop is allowed. The SDK numbers repeated ids in the order they are called, so the second charge-invoice below is stored as charge-invoice:1, the third as charge-invoice:2 and so on:

var invoices = await context.Step.RunAsync("load-invoices",
ct => billing.GetOpenInvoicesAsync(customerId, ct));
foreach (var invoice in invoices)
{
await context.Step.RunAsync("charge-invoice", ct => billing.ChargeAsync(invoice.Id, ct));
}

This works because the list comes from a step, so it is the same on every call. Ids built from stable data, such as $"charge-{invoice.Id}", work too and are easier to read in the run history.

Inngest stores results under the SHA-1 of the id, and the run history shows the id you wrote.

RunAsync takes a handler in whichever shape fits:

// Asynchronous, with the call's cancellation token.
var invoices = await context.Step.RunAsync("load", ct => billing.GetOpenInvoicesAsync("cus_42", ct));
// Asynchronous without a token, or synchronous.
var page = await context.Step.RunAsync("fetch", () => http.GetStringAsync("https://example.com/feed"));
var reference = await context.Step.RunAsync("create-reference", () => Guid.NewGuid().ToString("N"));
// No result.
await context.Step.RunAsync("notify", ct => billing.NotifyAsync("cus_42", ct));

The cancellation token passed to the handler is canceled when Inngest abandons the call or the app shuts down.

A handler that returns a ValueTask is rejected with an ArgumentException, because the SDK would otherwise save the ValueTask itself instead of waiting for it. Make the lambda async or call AsTask().

A step’s result is serialized to JSON and stored by Inngest; every later call deserializes it. The SDK does this on the call that ran the step too, so the function always sees the result the same way. Return types that round-trip through System.Text.Json: records, DTOs, primitives, lists. Do not return services, streams, or entities with navigation properties.

A run keeps the results its steps returned, and every later call reads them with the code deployed then. When a change to a step’s result type leaves stored JSON that no longer fits it, such as a property that became a number, the runs in flight fail when they reach that step. The error names the step and the type, and the run is retried as for any other error, so a fix or a rollback deployed before the retries run out lets those runs go on. To change what a step returns, keep the new type able to read the old JSON, or give the step a new id, so that runs in flight run it again instead of reading the old result.

Results count toward Inngest’s limits: a few MiB per step and in total per run. Store large data elsewhere and return a reference to it.

Code outside steps runs on every call. That is fine for reading the event, computing values from step results and branching on them. Anything that changes the world, or that gives a different answer each time, belongs in a step: sending email, writing to a database, calling an API, reading the clock, generating ids.

The same goes for variables. A handler does not run again once its step is memoized, so a value assigned inside it is lost on later calls. Return the value instead:

// Wrong: total is only assigned on the call that runs the step. On later calls it stays 0.
var total = 0m;
await context.Step.RunAsync("sum-invoices", async ct => { total = await billing.SumOpenAsync("cus_42", ct); });
// Right: the result is saved and handed back on every call.
var total = await context.Step.RunAsync("sum-invoices", ct => billing.SumOpenAsync("cus_42", ct));

context.Logger stays quiet while the function replays memoized steps and starts writing once it reaches new code, so a message between two steps is written once per run instead of once per call. Loggers you inject yourself do not know about replays.

var paymentId = await context.Step.RunAsync("charge",
ct => payments.ChargeAsync(context.Event.Data.CustomerId, context.Event.Data.Total, ct));
context.Logger.LogInformation("Charged order {OrderId} with payment {PaymentId}", context.Event.Data.OrderId, paymentId);

Calling a step tool inside another step’s handler throws an InvalidOperationException. Call steps from the function itself, one after the other or in parallel.

A model call is a step like any other, and two step tools make it show as one in the Inngest UI. InferAsync has Inngest make the request to the provider, so no worker waits for the answer. AIWrapAsync runs your own call, such as one through IChatClient of Microsoft.Extensions.AI behind a client of your own, as a run step that the UI shows with its input:

var note = await context.Step.AIWrapAsync(
"draft-note",
$"Write a short thank-you note for order {context.Event.Data.OrderId}.",
prompt => chat.CompleteAsync(prompt, cancellationToken));

The input is saved with the step, and a rerun from the Inngest UI with an edited input hands the handler the edited one. Keep the input serializable, as a step’s result is.

For a whole IChatClient pipeline, with its tool loop, AI chat clients shows how the Inngest.Extensions.AI package makes every model call and tool call a step without wrapping each one by hand.

Each step has its own retry count: with Retries = 3, every step gets up to four attempts. When a step fails all of them, the SDK throws StepFailedException where the function awaits the step. Catch it to recover; left uncaught, it fails the run. Errors and retries covers this, along with NonRetriableException and RetryAfterException.

A handler can also run again after its work succeeded, when the call that ran it is lost before Inngest records the result. Write steps that are safe to repeat, such as a database write keyed by the run and the step; Scoped steps shows one.

Order fulfillment and nightly billing are built from steps throughout.

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