Task Dispatching

EverTask provides several ways to dispatch tasks for immediate, delayed, or scheduled execution. This guide covers all dispatching patterns.

Table of Contents

Getting the Dispatcher

The dispatcher is available through dependency injection via the ITaskDispatcher interface:

public class MyController : ControllerBase
{
    private readonly ITaskDispatcher _dispatcher;

    public MyController(ITaskDispatcher dispatcher)
    {
        _dispatcher = dispatcher;
    }

    // ... use _dispatcher
}

Or via service locator pattern (not recommended):

var dispatcher = serviceProvider.GetRequiredService<ITaskDispatcher>();

Fire-and-Forget Tasks

The simplest form of task dispatching queues a task for immediate background execution:

// Basic dispatch
await _dispatcher.Dispatch(new SendEmailTask(email, subject, body));

When you dispatch a task, it gets persisted to storage (if configured), added to the worker queue, and executed by the next available worker.

When to Use

Use fire-and-forget tasks for processing that doesn’t need to block the HTTP response, such as sending emails, updating caches, or generating thumbnails. They start immediately but don’t need to finish before you return a response.

Example: User Registration

[HttpPost("register")]
public async Task<IActionResult> RegisterUser(UserRegistrationDto dto)
{
    // Synchronous work (must complete before response)
    var user = await _userService.CreateUserAsync(dto);

    // Fire-and-forget tasks (run in background)
    await _dispatcher.Dispatch(new SendWelcomeEmailTask(user.Email, user.Name));
    await _dispatcher.Dispatch(new CreateUserProfileTask(user.Id));
    await _dispatcher.Dispatch(new NotifyAdminsTask(user.Id));

    return Ok(new { userId = user.Id });
}

Delayed Tasks

Delayed tasks execute after a specified time period using TimeSpan:

// Execute after 30 minutes
var delay = TimeSpan.FromMinutes(30);
await _dispatcher.Dispatch(
    new SendReminderTask(userId),
    delay);

When to Use

Delayed tasks suit reminders, follow-ups, retry mechanisms with backoff, or any workflow where something should happen after a specific amount of time passes.

Example: Order Processing Workflow

// Send order confirmation immediately
await _dispatcher.Dispatch(new SendOrderConfirmationTask(orderId));

// Check payment status after 5 minutes
await _dispatcher.Dispatch(
    new CheckPaymentStatusTask(orderId),
    TimeSpan.FromMinutes(5));

// Send reminder after 1 hour if not processed
await _dispatcher.Dispatch(
    new SendPaymentReminderTask(orderId),
    TimeSpan.FromHours(1));

// Cancel order after 24 hours if still pending
await _dispatcher.Dispatch(
    new CancelPendingOrderTask(orderId),
    TimeSpan.FromHours(24));

Delay Precision

The delay scheduler is precise: tasks typically execute within milliseconds of the scheduled time under normal load. Since v2.0 the timing comes from PeriodicTimerScheduler. Delayed tasks persist across application restarts, so you don’t lose them if your app goes down.

Scheduled Tasks

Scheduled tasks execute at a specific date and time using DateTimeOffset:

// Execute at a specific time
var scheduledTime = new DateTimeOffset(2024, 12, 25, 10, 0, 0, TimeSpan.Zero);
await _dispatcher.Dispatch(
    new SendChristmasGreetingTask(),
    scheduledTime);

When to Use

Scheduled tasks fit when you need something to happen at a specific date and time: maintenance windows, scheduled reports, campaign launches, or time-zone specific operations.

Example: Campaign Management

// Schedule marketing campaign for specific time
var campaignLaunchTime = new DateTimeOffset(2024, 12, 1, 9, 0, 0, TimeSpan.FromHours(-5)); // 9 AM EST
await _dispatcher.Dispatch(
    new LaunchMarketingCampaignTask(campaignId),
    campaignLaunchTime);

// Schedule report generation for end of month
var endOfMonth = new DateTimeOffset(2024, 12, 31, 23, 59, 0, TimeSpan.Zero);
await _dispatcher.Dispatch(
    new GenerateMonthlyReportTask(userId),
    endOfMonth);

Time Zone Considerations

// Schedule in user's local time zone
var userTimeZone = TimeZoneInfo.FindSystemTimeZoneById(user.TimeZoneId);
var localTime = new DateTimeOffset(2024, 12, 25, 10, 0, 0, userTimeZone.BaseUtcOffset);

await _dispatcher.Dispatch(
    new SendBirthdayGreetingTask(user.Id),
    localTime);

Scheduled Task Behavior

A few things to keep in mind: if the scheduled time is already in the past when you dispatch, the task will execute immediately. Scheduled tasks persist across application restarts, so they’ll still run even if your app goes down. For time zones, use UTC when you want absolute time regardless of location, or use specific offsets when you need local time behavior.

Capturing Task IDs

Every task you dispatch gets a unique Guid that you can capture and use later:

Guid taskId = await _dispatcher.Dispatch(new MyTask());

// Store the ID for later reference
await _database.SaveTaskIdAsync(orderId, taskId);

Using Task IDs

Cancellation

You can cancel a task whether it is still pending or already running:

// Dispatch task
Guid taskId = await _dispatcher.Dispatch(
    new ProcessPaymentTask(paymentId),
    TimeSpan.FromMinutes(10));

// User cancelled - stop the task
await _dispatcher.Cancel(taskId);

Note: A task that hasn’t started yet is removed from the queue before it runs. For a task that is already executing, Cancel signals the CancellationToken passed to the handler, so the handler must observe that token (for example with ThrowIfCancellationRequested) for cancellation to take effect.

Tracking

Task IDs are also useful for tracking operation status. For example, if you dispatch multiple related tasks, you can store their IDs to check on them later:

// Dispatch multiple related tasks
var emailTaskId = await _dispatcher.Dispatch(new SendEmailTask(...));
var smsTaskId = await _dispatcher.Dispatch(new SendSmsTask(...));

// Store for tracking
var notification = new Notification
{
    Id = notificationId,
    EmailTaskId = emailTaskId,
    SmsTaskId = smsTaskId
};
await _database.SaveAsync(notification);

Querying Task Status

// Later, check task status from storage.
// ITaskStorage.Get takes a predicate and returns matching rows.
var task = (await _taskStorage.Get(t => t.Id == taskId)).FirstOrDefault();

if (task == null)
    return; // No row for this ID

switch (task.Status)
{
    case QueuedTaskStatus.Queued:
        // Accepted into the queue, not started yet
        break;
    case QueuedTaskStatus.InProgress:
        // Currently executing
        break;
    case QueuedTaskStatus.Completed:
        // Finished successfully
        break;
    case QueuedTaskStatus.Failed:
        // Failed after all retries
        break;
    case QueuedTaskStatus.Cancelled:
        // Was cancelled
        break;
}

Dispatch Patterns

Batch Dispatching

When you need to dispatch multiple tasks, you can loop through them and collect the task IDs:

var taskIds = new List<Guid>();

foreach (var user in users)
{
    var taskId = await _dispatcher.Dispatch(new SendNewsletterTask(user.Id));
    taskIds.Add(taskId);
}

// Store all task IDs
await _database.SaveBatchTaskIdsAsync(batchId, taskIds);

Conditional Dispatching

Sometimes you want different dispatch strategies based on your business logic:

if (order.Total > 1000)
{
    // High-value orders get immediate processing
    await _dispatcher.Dispatch(new ProcessHighValueOrderTask(order.Id));
}
else
{
    // Regular orders can be delayed
    await _dispatcher.Dispatch(
        new ProcessRegularOrderTask(order.Id),
        TimeSpan.FromMinutes(5));
}

Task Chains

You can build sequential workflows by dispatching the next task when the previous one completes:

// In a handler. OnCompleted receives only the task ID, so capture any
// payload value you need in Handle and read it back here.
private Guid _correlationId;

public override async Task Handle(FirstStepTask task, CancellationToken ct)
{
    _correlationId = task.CorrelationId;
    // ... first step logic
}

public override async ValueTask OnCompleted(Guid taskId)
{
    // First task completed, dispatch next step
    await _dispatcher.Dispatch(new SecondStepTask(_correlationId));
}

See Task Continuations for more details.

Error Recovery

When things go wrong, you can dispatch compensating tasks to roll back or clean up:

// In a handler. As with OnCompleted, capture the value in Handle (_operationId)
// since OnError only receives the task ID.
public override async ValueTask OnError(Guid taskId, Exception? exception, string? message)
{
    _logger.LogError(exception, "Task {TaskId} failed, dispatching rollback", taskId);

    // Dispatch compensating task
    await _dispatcher.Dispatch(new RollbackOperationTask(_operationId));
}

Best Practices

1. Always Await Dispatch

Don’t fire-and-forget your dispatch calls - always await them to ensure the task gets persisted:

// ✅ Good: Await to ensure persistence
await _dispatcher.Dispatch(new MyTask());

// ❌ Bad: Fire-and-forget without await (might not persist)
_ = _dispatcher.Dispatch(new MyTask()); // DON'T DO THIS

2. Handle Dispatch Failures

Wrap dispatch calls in try-catch blocks so you can handle failures gracefully:

try
{
    await _dispatcher.Dispatch(new MyTask());
}
catch (Exception ex)
{
    _logger.LogError(ex, "Failed to dispatch task");
    // Handle error (retry, alert, fallback, etc.)
}

3. Use Appropriate Timing

Choose the right timing method for your use case - TimeSpan for relative delays, DateTimeOffset for specific times:

// ✅ Good: Relative delay for "X time from now"
await _dispatcher.Dispatch(task, TimeSpan.FromHours(1));

// ✅ Good: Absolute schedule for specific time
await _dispatcher.Dispatch(task, new DateTimeOffset(2024, 12, 25, 10, 0, 0, TimeSpan.Zero));

// ❌ Bad: Absolute time for relative delay (harder to understand)
await _dispatcher.Dispatch(task, DateTimeOffset.UtcNow.AddHours(1));

4. Store Important Task IDs

Capture task IDs when you need to track or cancel tasks, but don’t bother if it’s truly fire-and-forget:

// ✅ Good: Store task ID when you need to track or cancel
var taskId = await _dispatcher.Dispatch(new CriticalTask(...));
await _database.SaveTaskIdAsync(referenceId, taskId);

// ✅ Good: Ignore task ID for fire-and-forget
await _dispatcher.Dispatch(new LoggingTask(...));

5. Consider Idempotency

For recurring tasks, use task keys to prevent accidentally registering the same task multiple times:

// For critical recurring dispatches, use task keys to prevent duplicates
await _dispatcher.Dispatch(
    new DailyReportTask(),
    recurring => recurring.Schedule().EveryDay(),
    taskKey: "daily-report"); // Prevents duplicate registration

Every Dispatch overload accepts the same three optional parameters after the scheduling argument: auditLevel (per-dispatch override of how much audit trail to persist), taskKey (the idempotency key shown above), and cancellationToken (cancels the dispatch operation itself, such as a blocking enqueue on a full queue, not the task’s execution). Each is optional and can be passed by name:

await _dispatcher.Dispatch(
    new DailyReportTask(),
    recurring => recurring.Schedule().EveryDay(),
    auditLevel: AuditLevel.Minimal,
    taskKey: "daily-report",
    cancellationToken: ct);

See Idempotent Task Registration for more details, and Dispatch Parameters for the full reference.

Performance Considerations

Queue Capacity

If you’re dispatching a lot of tasks quickly, you might need to bump up the queue capacity:

// Configure sufficient capacity in startup
builder.Services.AddEverTask(opt =>
{
    opt.SetChannelOptions(5000); // Increase if dispatching in bulk
});

Batching Database Operations

When you’re dispatching thousands of tasks, think about batching instead of individual dispatches:

// Less efficient: Many small dispatches
foreach (var user in users) // 10,000 users
{
    await _dispatcher.Dispatch(new SendEmailTask(user.Id));
}

// More efficient: Batch dispatch
await _dispatcher.Dispatch(new SendBulkEmailTask(users.Select(u => u.Id).ToList()));

High-Load Scenarios

If you register delayed or recurring tasks at a very high rate, the sharded scheduler spreads the scheduler’s priority queue across shards to cut lock contention on Schedule(). It does not speed up immediate dispatch or task execution; those are bounded by the channel and your storage, not the scheduler:

builder.Services.AddEverTask(opt =>
{
    opt.RegisterTasksFromAssembly(typeof(Program).Assembly)
       .UseShardedScheduler(); // Auto-scale with CPU cores
});

Check out Sharded Scheduler for the full details.

Next Steps


Copyright © 2025 Giampaolo Gabba. Distributed under the APACHE 2.0 License.

This site uses Just the Docs, a documentation theme for Jekyll.