Skip to content

Transactional outbox

Sending an event right after saving data can go wrong in two ways. If the process dies between the save and the send, the data is stored and the event is lost. If the event is sent first and the save fails, Inngest starts functions for work that never happened. IInngestClient.SendAsync cannot be part of a database transaction.

The outbox of Inngest.EntityFrameworkCore makes the event part of the transaction. AddInngestEvent adds a row to a table of your database through the same DbContext, so SaveChanges stores the data and the event together, or neither. A hosted dispatcher reads the table and sends the events to Inngest after the commit.

Add the package. The net8.0 build is made for Entity Framework Core 8 and the net10.0 build for Entity Framework Core 10. Entity Framework Core 9 is not supported: an app on .NET 9 gets the net8.0 build, which needs Entity Framework Core 8, and with Entity Framework Core 9 the host refuses to start and says so.

Terminal window
dotnet add package Inngest.EntityFrameworkCore

Map the table in the model of the DbContext that holds your data, and add a migration for it as for any other table (dotnet ef migrations add AddInngestOutbox):

ShopContext.cs
using Microsoft.EntityFrameworkCore;
public sealed class Order
{
public int Id { get; set; }
public string Number { get; set; } = string.Empty;
public string CustomerId { get; set; } = string.Empty;
public decimal Total { get; set; }
}
public sealed class ShopContext(DbContextOptions<ShopContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
// Maps the InngestOutbox table next to the app's own.
modelBuilder.AddInngestOutbox();
}

Register the outbox on the Inngest builder, and add UseInngestOutbox to the options of the context:

Program.cs
using Inngest;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddInngest(options => options.AppId = "shop")
.AddEntityFrameworkOutbox<ShopContext>();
builder.Services.AddDbContext<ShopContext>(options => options
.UseNpgsql(builder.Configuration.GetConnectionString("Shop"))
.UseInngestOutbox());
var app = builder.Build();
app.MapInngest();
app.Run();

AddEntityFrameworkOutbox adds the hosted dispatcher of that context. Call it for each DbContext that has events to send; each has its own table, settings and dispatcher. UseInngestOutbox is an interceptor that makes the events leave sooner: it wakes the dispatcher when a save that added events has committed, where without it they leave at the next poll, 5 seconds later by default. It finds the dispatcher through the application’s services that Entity Framework leaves in the options of the context, so it can be called wherever the options are built, in the callbacks of AddDbContext and AddDbContextPool with or without a service provider, of AddDbContextFactory, and in OnConfiguring. When the dispatcher starts, it says once, at the information level, if the context has no UseInngestOutbox. UseInngestOutbox(services) does the same and also fails when the options are built if the outbox is not registered.

AddInngestEvent takes a record that implements IInngestEventData, the same records that SendAsync takes. It sends nothing: the event is a row in the unit of work that SaveChangesAsync stores.

CheckoutService.cs
using Inngest;
using Microsoft.EntityFrameworkCore;
public sealed class CheckoutService(ShopContext db)
{
public async Task PlaceOrderAsync(Order order, CancellationToken cancellationToken)
{
db.Orders.Add(order);
db.AddInngestEvent(new OrderPlaced(order.Number, order.CustomerId, order.Total));
// The order and the event are stored together, or neither is.
await db.SaveChangesAsync(cancellationToken);
}
}

The data is serialized when the event is added, with InngestOptions.SerializerOptions, as SendAsync does, so what the SDK could send can be added, and what it could not fails here, with the code that added the event still on the stack: a name that is empty or reserved, data that is not a JSON object, a type that the serializer options have no metadata for in an app that trims. In an app that trims, add the event types to a source-generated context in the options, as for SendAsync.

The method returns the row. Its Id is the id the event is sent under, and the timestamp of the event is the time AddInngestEvent ran, not the time the dispatcher got to it. For events named by a string, or to set the user data, the version, the sessions or the timestamp, add an InngestEvent:

using Inngest;
using Microsoft.EntityFrameworkCore;
db.AddInngestEvent(new InngestEvent("support/ticket.opened", new { TicketId = "t_42" })
{
Id = "ticket-opened-t_42",
Version = "2026-10-01",
});
// The id of a typed event is the second argument.
db.AddInngestEvent(new OrderPlaced(order.Number, order.CustomerId, order.Total), id: $"order-placed-{order.Number}");

Without an Id, the event gets a generated one: a ULID, the format of the ids Inngest gives its own events. Give one when the event stands for something the code can name, such as an order. The column is the primary key of the table, so a second attempt at the same work fails the save with a DbUpdateException instead of announcing the order twice. The key is checked against the rows that are still in the table, which are kept for the retention time.

  • The id, the name and the version are limited to 255 characters. Longer ones fail when the event is added.
  • A database that compares text without regard to case, which is the default of SQL Server and MySQL, takes two ids that differ only in case for the same key, so the second one fails the save. PostgreSQL and SQLite tell them apart.
  • An event added inside a function does not carry the sessions of the run (FunctionContext.Sessions), because the dispatcher sends it outside the run. Set Meta.Sessions on the event if it should belong to a session.

The dispatcher of each context repeats three steps. It starts when the host starts and takes no more work when it stops.

  1. Claim. It takes up to BatchSize events that are due. The claim is an UPDATE through ExecuteUpdate that writes a token of its own and an expiry (LeaseDuration) on those rows, and then it reads the rows that hold its token. Nothing is locked, so any number of dispatchers, in one process or in many, share the table and an event is claimed by one of them at a time. Nothing in it is specific to a provider.
  2. Send. The whole batch goes to Inngest in one request, each event under its own id.
  3. Record. The events Inngest took get SentAt. The others stay, with Attempts counted, LastError set and NextAttemptAt moved on.

Three things start a pass: a save that added events and has committed, the poll interval, and the end of a pass that found events, which may have left more behind. When the save used a transaction of the code’s own, the wake-up comes with its commit, not with the save. With a TransactionScope it comes at the save, before the scope completes, so the dispatcher finds nothing and the events leave at the next poll. A transaction that is committed outside Entity Framework Core gives no wake-up, and the poll finds the events.

A dispatcher can send a batch and die before it records that, or lose the connection to the database at that point. The events are then still due when their lease runs out, and another dispatcher sends them again. Delivery is at least once.

An event keeps the id it was added with, on every attempt, so the repeat is an event with an id that Inngest has seen. Inngest’s documentation says it drops a repeated id for 24 hours. What was checked here: against the dev server (inngest/inngest:v1.49.0) a send that repeated an id seconds after the first was answered with a success and an event id of its own, and the function it triggers started no run. The dev server’s source, read at the v1.49.0 tag, keeps the key of a run for 24 hours. Not checked: Inngest Cloud, a repeat after hours, and whether a repeat can also resume a run that waits for the event. A resend that comes later than 24 hours, which takes a dispatcher that was down for as long, would start runs again.

An event is never given up and never deleted before it is sent. A failed request leaves its events in the table, and each is tried again after RetryDelay, then after twice that, and so on up to MaxRetryDelay, counted per event from its own failures. Attempts and LastError say how often and why.

When Inngest turns a request down because of its events (400, 409, 412, 413 or 422), for example an event that is too large, the events of that request are sent one at a time, so that the one it objects to does not hold back the others. Each event that is refused stays in the table and is tried again at the retry interval, with an error in the log at every attempt that names its id and its name. It will not go through until the row is fixed or deleted. If Inngest is down or the event key is wrong, the events are not split: they fail together, as one request.

Events that are too large for a request together, but not alone, are sent one at a time after every refused batch, so lower BatchSize for large events. A request that takes longer than SendTimeout counts as failed. When the host stops, the events a dispatcher had claimed are given back at once and do not cost an attempt, so another instance sends them without waiting for the lease.

Events are sent oldest first within a batch, but nothing keeps one event from arriving after a later one: batches go out from several dispatchers, and a failed event waits while its neighbours pass. Inngest does not promise an order between events either. If a function needs one, put a sequence number or a version in the data.

The instances compare their own clocks with times that other instances wrote: when a lease ends, when a retry is due and whether a new event is due. Keep the clocks in step. A dispatcher whose clock runs ahead takes events from one that is still sending them only if it is ahead by more than LeaseDuration minus SendTimeout, which is 40 seconds with the defaults, and a repeat is dropped by its id in any case. An event saved by an instance whose clock runs ahead, and that runs no dispatcher itself, is not due for the dispatchers of the others until their clocks reach its time, so it waits as long as the clock is ahead. The ts it is sent with is that clock’s time too.

Events that Inngest took stay in the table for Retention, one day by default, and are then deleted in batches of CleanupBatchSize with ExecuteDelete. The retention time keeps an id that the code chose from being added again while Inngest still drops it, so it should not be shorter than the time Inngest remembers ids. Events that are not sent are never deleted.

The settings are the same for every instance of the app, because each runs a dispatcher. They are checked when the host starts.

inngest.AddEntityFrameworkOutbox<ShopContext>(options =>
{
options.PollInterval = TimeSpan.FromSeconds(2);
options.BatchSize = 200;
options.Retention = TimeSpan.FromDays(2);
});
Option Default Meaning
DispatcherEnabled true Runs the dispatcher in this process. Turn it off in a process that only adds events, and keep it on in at least one other.
PollInterval 5 seconds How long the dispatcher waits for a wake-up before it looks anyway. This is how it finds events that other processes saved and retries that fell due.
BatchSize 100 The most events one claim takes and one request sends.
LeaseDuration 1 minute How long a dispatcher owns what it claimed. After a crash its events wait this long. At least twice SendTimeout, so that a request Inngest refuses can still be followed by requests for single events.
SendTimeout 20 seconds How long one request may take, the retries of the SDK’s client included.
RetryDelay 5 seconds The wait after an event’s first failed attempt. Each further failure doubles it.
MaxRetryDelay 5 minutes The longest wait between attempts.
Retention 1 day How long sent events stay before they are deleted.
CleanupInterval 5 minutes How often sent events past their retention time are deleted.
CleanupBatchSize 1000 How many events one DELETE removes.

The rows are InngestOutboxMessage objects. The table shows what is waiting and why:

using Microsoft.EntityFrameworkCore;
var cutoff = DateTime.UtcNow.AddMinutes(-5);
var stuck = await db.Set<InngestOutboxMessage>()
.Where(message => message.SentAt == null && message.CreatedAt < cutoff)
.OrderBy(message => message.CreatedAt)
.ToListAsync(cancellationToken);
foreach (var message in stuck)
{
Console.WriteLine($"{message.Id} {message.Name}: {message.Attempts} attempts, {message.LastError}");
}

All times in the table are UTC. CreatedAt is when the row was added. Timestamp is the event’s own time, which the code can set to any time, so it says nothing about how long a row has waited. Data, User and Sessions hold the event as JSON text.

  • The table holds the event as the SDK serialized it. Middleware that changes events as they leave, such as encryption, runs when the dispatcher sends them, so the table holds the plain data. Middleware that wraps sends sees these events with no run, because no function sends them.
  • Entity Framework Core 8 and 10. Entity Framework Core 9 is not supported. ExecuteUpdate lives in a different assembly in Entity Framework Core 8 and in 9 and later, so the net8.0 build, which an app on .NET 9 gets, only runs on Entity Framework Core 8.
  • Providers. The dispatcher uses only LINQ that Entity Framework Core translates for relational providers. The tests of this repository run it on SQLite with Entity Framework Core 8 and 10, and on PostgreSQL with Entity Framework Core 10 and Npgsql 10 on .NET 10 only. Other providers, and other versions of the PostgreSQL provider, were not tried.
  • SQL Server. With its default locking READ COMMITTED, a query waits for rows that open transactions have inserted. The dispatcher’s query reads the part of the index that holds the events a transaction has just added, so one long unit of work that adds an event stalls the dispatchers of every instance until it commits, or until the command times out. Turn on READ_COMMITTED_SNAPSHOT for the database (ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON), which Azure SQL Database has by default. This was not run on SQL Server; it follows from how its locking works. PostgreSQL readers do not wait for writers, which the tests show with an event that a transaction has saved and not committed.
  • One database per registration. A dispatcher reads the database that TContext connects to in a new scope. An app that picks the database per tenant is not covered.
  • No Native AOT. Entity Framework Core does not support it, and the package does not declare itself compatible. The events are still serialized from the metadata of the serializer options, without reflection of its own.
  • A host that stops loses nothing. The events are in the table, and whichever instance runs next sends them.
  • Tests. After a save, a test can read the rows of Set<InngestOutboxMessage>() to see what was added. To run the dispatcher too, use SQLite in memory and register a fake IInngestClient before AddInngest, which keeps a client that is already registered. The package’s own tests are built that way.

The dispatcher logs under categories that start with Inngest.EntityFrameworkCore, with event ids 800 to 810.

See Sending events for events that are not part of a transaction.

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