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
Call 2replays charge, runs mark-paid
Call 3replays both and returns
- runs the handler
- returns the saved result
charge runs a single time even though three calls pass it.The step tools
Section titled “The step tools”| 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.
Step ids
Section titled “Step ids”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.
Handlers
Section titled “Handlers”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().
Results are JSON
Section titled “Results are JSON”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.
Keep side effects in steps
Section titled “Keep side effects in steps”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));Logging
Section titled “Logging”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);Steps cannot be nested
Section titled “Steps cannot be nested”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.
Calls to AI models
Section titled “Calls to AI models”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.
Retries and failures
Section titled “Retries and failures”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.
Related examples
Section titled “Related examples”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.