Skip to content

From BackgroundService

Replace hand-written timer loops, Channel queues and retry code with Immediate.Jobs.

3 min read

Many applications start with a BackgroundService that loops on a timer, or reads work from a Channel<T>. These work until the application runs on more than one instance, restarts with work in memory, or needs retries and monitoring. Immediate.Jobs replaces the loop, the queue and the retry code, and keeps the work itself in an ordinary handler.

Timer loops

Before: BackgroundService
public sealed class CleanupWorker(IServiceScopeFactory scopes, ILogger<CleanupWorker> logger)
	: BackgroundService
{
	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
		while (await timer.WaitForNextTickAsync(stoppingToken))
		{
			try
			{
				await using var scope = scopes.CreateAsyncScope();
				var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
				await db.DeleteExpiredSessions(stoppingToken);
			}
			catch (Exception ex) when (ex is not OperationCanceledException)
			{
				logger.LogError(ex, "Cleanup failed");
			}
		}
	}
}

builder.Services.AddHostedService<CleanupWorker>();
After: Immediate.Jobs
[Handler, Job(Name = "cleanup-sessions", Cron = "*/5 * * * *", OverlapPolicy = OverlapPolicy.Skip)]
public sealed partial class CleanupSessionsJob(AppDbContext db)
{
	private ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) =>
		new(db.DeleteExpiredSessions(cancellationToken));
}
  • The scope, the try/catch and the logging go away. Each run gets its own DI scope, failures are retried and logged, and every attempt is recorded for the dashboard.
  • A PeriodicTimer interval becomes a cron expression. Cron runs at fixed clock times (:00, :05, …) instead of five minutes after the application started. Use a six-field expression such as */30 * * * * * for intervals shorter than a minute.
  • With durable storage and UseDistributed(), each run happens once across all instances instead of once per instance.
  • OverlapPolicy.Skip keeps the old behavior of never running two cleanups at once. Choose MisfireHandlingMode for what should happen after downtime; the old loop simply started over.

Channel-based queues

Before: BackgroundService
public sealed class EmailQueue
{
	private readonly Channel<WelcomeEmail> _channel = Channel.CreateUnbounded<WelcomeEmail>();

	public ValueTask EnqueueAsync(WelcomeEmail email, CancellationToken token) =>
		_channel.Writer.WriteAsync(email, token);

	public IAsyncEnumerable<WelcomeEmail> ReadAllAsync(CancellationToken token) =>
		_channel.Reader.ReadAllAsync(token);
}

public sealed class EmailWorker(EmailQueue queue, IServiceScopeFactory scopes) : BackgroundService
{
	protected override async Task ExecuteAsync(CancellationToken stoppingToken)
	{
		await foreach (var email in queue.ReadAllAsync(stoppingToken))
		{
			await using var scope = scopes.CreateAsyncScope();
			var sender = scope.ServiceProvider.GetRequiredService<IEmailSender>();
			await sender.SendAsync(email.UserId, email.Template, stoppingToken);
		}
	}
}
After: Immediate.Jobs
[Handler, Job(Name = "send-welcome-email", MaxAttempts = 5)]
public sealed partial class SendWelcomeEmail(IEmailSender sender)
{
	public sealed record Payload(Guid UserId, string Template);

	private ValueTask HandleAsync(Payload payload, CancellationToken cancellationToken) =>
		new(sender.SendAsync(payload.UserId, payload.Template, cancellationToken));
}

// Inject SendWelcomeEmail.Scheduler where you injected EmailQueue.
await scheduler.EnqueueAsync(new(userId, "v2"), cancellationToken);
  • The queue class, the worker and its registration are removed. The item type becomes the job’s Payload.
  • Items survive restarts once you choose a durable provider. With UseInMemory(), behavior matches the old in-memory channel.
  • Several consumers reading from one channel become WorkerCount, MaxConcurrency on the job, or a queue with its own Concurrency.
  • A bounded channel used for back pressure has no direct equivalent: enqueueing always succeeds once the job is stored. Limit throughput with concurrency settings instead.
  • Delayed retries implemented with Task.Delay become Backoff and BackoffBase. Work that must start later becomes ScheduleAsync.

Retries, shutdown and health

Hand-writtenImmediate.Jobs
Retry loop with Task.DelayMaxAttempts, Backoff, BackoffBase
CancellationTokenSource with a timeoutTimeout on [Job]
Waiting for work in StopAsyncShutdownTimeout drains claimed jobs before cancelling them
Custom “last run” health checkAddHealthCheck() storage and service checks
Logging around each itemBuilt-in logs, OpenTelemetry traces and metrics, or a handler behavior
Scoped services from IServiceScopeFactoryConstructor or HandleAsync parameters; each attempt gets a scope

Migrate with an agent

Migrate hand-written workers with an AI agent

Copy this prompt into Claude Code, Codex, Copilot or another coding agent from the root of your repository.