Skip to content

Scheduled reminders with sleep and cancel

Reminders are runs that spend nearly all their life asleep. One run per reminder sleeps until the due time and then sends the notification. Deleting the reminder cancels the run; moving it starts a new run that replaces the old one.

SendReminder.cs
using Inngest;
public sealed record ReminderScheduled(string ReminderId, string UserId, string Text, DateTimeOffset DueAt) : IInngestEventData
{
public static string EventName => "reminders/reminder.scheduled";
}
public sealed record ReminderDeleted(string ReminderId) : IInngestEventData
{
public static string EventName => "reminders/reminder.deleted";
}
public sealed class SendReminder(INotifier notifier) : InngestFunction<ReminderScheduled>
{
public override async Task<object?> ExecuteAsync(FunctionContext<ReminderScheduled> context, CancellationToken cancellationToken)
{
var reminder = context.Event.Data;
// DueAt comes from the event, so it is the same on every call.
await context.Step.SleepUntilAsync("wait-until-due", reminder.DueAt);
await context.Step.RunAsync("notify", ct => notifier.NotifyAsync(reminder.UserId, reminder.Text, ct));
return new { reminder.ReminderId };
}
}
public interface INotifier
{
Task NotifyAsync(string userId, string text, CancellationToken cancellationToken);
}

The app registers it with its options as attributes on the class, or with the options in code:

[InngestFunction("send-reminder")]
[EventTrigger<ReminderScheduled>]
// A newer schedule for the same reminder cancels the pending run.
[Singleton(SingletonMode.Cancel, Key = "event.data.reminderId")]
[CancelOn<ReminderDeleted>(Match = "data.reminderId")]

The attributes go on SendReminder, and AddFunction reads them once at startup:

Program.cs (registration)
inngest.AddFunction<SendReminder>();

The app sends an event whenever a reminder is created or moved, and another when it is deleted:

ReminderService.cs
using Inngest;
public sealed class ReminderService(IInngestClient inngest)
{
public Task ScheduleAsync(string reminderId, string userId, string text, DateTimeOffset dueAt, CancellationToken cancellationToken) =>
inngest.SendAsync(new ReminderScheduled(reminderId, userId, text, dueAt), cancellationToken);
public Task DeleteAsync(string reminderId, CancellationToken cancellationToken) =>
inngest.SendAsync(new ReminderDeleted(reminderId), cancellationToken);
}
  • Sleeping costs nothing. SleepUntilAsync ends the call. Inngest calls the function again at the due time, which can be weeks away and on another instance of the app.
  • Moving a reminder. Sending reminders/reminder.scheduled again for the same reminder starts a new run. The singleton option in cancel mode, keyed on the reminder id, cancels the run that was waiting for the old time.
  • Deleting a reminder. The cancellation on ReminderDeleted, with Match = "data.reminderId", cancels the sleeping run when the deletion event names the same reminder.
  • Stable times. The due time comes from the event, which is the same on every call. A time computed from the clock outside a step would move each time the function replays.

The sleep and wait guide covers sleeps in detail, and Flow control the singleton option.

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