Overview
Background jobs are tasks that run outside the normal request-response path of an application. In .NET, they are commonly implemented using IHostedService, BackgroundService, worker services, timers, queues, message consumers, scheduled jobs, and long-running processors.
Examples of background jobs include:
- Sending emails after a user registers.
- Processing uploaded files.
- Generating reports.
- Running scheduled cleanup jobs.
- Polling an external API.
- Processing messages from Azure Service Bus, RabbitMQ, Kafka, or storage queues.
- Retrying failed payments.
- Synchronizing data between systems.
- Running nightly batch operations.
- Publishing notifications.
- Updating search indexes.
- Processing domain events or integration events.
Testing background jobs is different from testing normal request-response code. A controller action or Minimal API endpoint usually has a clear input and output. A background job often runs in a loop, waits on a timer, reads from a queue, uses cancellation tokens, creates dependency injection scopes, handles retries, writes logs, and performs side effects. This makes background jobs easy to write but difficult to test well if the design is not separated properly.
This topic focuses on how to test background jobs at different levels:
- Unit tests for the job logic.
- Unit tests for queue and scheduling abstractions.
- Integration tests for dependency injection, database, queue behavior, and hosted service wiring.
- ASP.NET Core integration tests that decide whether hosted services should run or be replaced.
- End-to-end or environment tests for real message brokers, cloud queues, or deployed worker processes.
- CI test execution considerations for long-running or asynchronous workers.
This topic matters because background jobs often contain important production behavior but are commonly under-tested. Bugs in background jobs may not be visible immediately. They may silently drop messages, process the same message twice, leak scoped services, block application startup, ignore cancellation, fail to shut down cleanly, or create duplicate side effects.
This topic is important for interviews because it tests practical .NET production knowledge. Interviewers often ask:
- What is the difference between
IHostedServiceandBackgroundService? - How do you test a
BackgroundService? - Should hosted services run during API integration tests?
- How do you unit test background job logic without waiting for real timers?
- How do you use scoped services from a hosted service?
- Why should a hosted service not directly inject
DbContext? - How do you test retry and failure behavior?
- How do you prevent flaky tests for asynchronous background processing?
- How do you gracefully stop a worker?
- How do you test queue-based background jobs?
- What should happen when a background job throws an exception?
- How do you observe background jobs in production?
A strong answer should explain that the hosted service should usually be a thin orchestration layer. The business logic should live in testable services that can be unit tested directly. Integration tests should verify wiring, persistence, queues, dependency injection scopes, cancellation, and real infrastructure behavior when needed. Tests should avoid real sleeps, uncontrolled timers, real external systems, and hidden shared state.
Core Concepts
What a Background Job Is
A background job is work that runs independently from the immediate caller.
In a Web API, this often means:
HTTP request arrives
-> app validates request
-> app stores work item or publishes message
-> app returns response
-> background worker processes the work later
Example:
POST /api/reports
-> create report request row in database
-> return 202 Accepted
-> background worker picks up pending report
-> generate PDF
-> upload to blob storage
-> update report status
-> notify user
This design improves user experience because the request does not wait for the whole long-running report generation process.
However, it introduces testing challenges because the actual result happens later and often outside the original request.
Hosted Services in .NET
A hosted service is a background service managed by the .NET host. It implements IHostedService.
The interface has two methods:
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
The host calls StartAsync when the application starts and StopAsync during graceful shutdown.
In most cases, long-running workers inherit from BackgroundService.
public abstract class BackgroundService : IHostedService, IDisposable
{
protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
}
Example:
public sealed class ReportWorker : BackgroundService
{
private readonly ILogger<ReportWorker> _logger;
public ReportWorker(ILogger<ReportWorker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Report worker is running.");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
}
Registration:
builder.Services.AddHostedService<ReportWorker>();
The hosted service is started by the application host, not by a controller or normal service call.
IHostedService vs BackgroundService
IHostedService is the low-level interface. BackgroundService is a base class that implements most of the host integration and lets you focus on ExecuteAsync.
For most long-running workers, prefer BackgroundService.
For one-time startup tasks or custom lifecycle behavior, IHostedService can be useful, but be careful: StartAsync should be short-running because hosted services start sequentially.
The Testing Problem with Hosted Services
A naive hosted service is hard to test.
Bad design:
public sealed class InvoiceWorker : BackgroundService
{
private readonly AppDbContext _context;
private readonly IEmailSender _emailSender;
public InvoiceWorker(AppDbContext context, IEmailSender emailSender)
{
_context = context;
_emailSender = emailSender;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var invoices = await _context.Invoices
.Where(i => i.Status == InvoiceStatus.Pending)
.ToListAsync(stoppingToken);
foreach (var invoice in invoices)
{
invoice.Status = InvoiceStatus.Sent;
await _emailSender.SendAsync(
invoice.CustomerEmail,
"Invoice",
"Your invoice is ready.",
stoppingToken);
}
await _context.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
Problems:
- Business logic is trapped inside the infinite loop.
- Uses
DbContextdirectly in a long-lived hosted service. - Hard to run only one iteration in a test.
- Requires waiting for real time.
- Difficult to assert behavior.
- Difficult to replace email sender cleanly.
- Hard to test cancellation.
- Hard to test failure behavior.
- The worker orchestration and job logic are mixed.
Better design separates orchestration from job logic.
Separate Worker Orchestration from Job Logic
The hosted service should usually be thin. It should handle scheduling, cancellation, scoping, and looping. The actual business work should be in a separate service.
Job logic interface:
public interface IInvoiceJob
{
Task ProcessPendingInvoicesAsync(CancellationToken cancellationToken);
}
Job logic implementation:
public sealed class InvoiceJob : IInvoiceJob
{
private readonly AppDbContext _context;
private readonly IEmailSender _emailSender;
public InvoiceJob(AppDbContext context, IEmailSender emailSender)
{
_context = context;
_emailSender = emailSender;
}
public async Task ProcessPendingInvoicesAsync(CancellationToken cancellationToken)
{
var invoices = await _context.Invoices
.Where(i => i.Status == InvoiceStatus.Pending)
.ToListAsync(cancellationToken);
foreach (var invoice in invoices)
{
await _emailSender.SendAsync(
invoice.CustomerEmail,
"Invoice",
"Your invoice is ready.",
cancellationToken);
invoice.Status = InvoiceStatus.Sent;
}
await _context.SaveChangesAsync(cancellationToken);
}
}
Hosted service:
public sealed class InvoiceWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<InvoiceWorker> _logger;
public InvoiceWorker(
IServiceScopeFactory scopeFactory,
ILogger<InvoiceWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
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 = _scopeFactory.CreateAsyncScope();
var job = scope.ServiceProvider
.GetRequiredService<IInvoiceJob>();
await job.ProcessPendingInvoicesAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Invoice worker failed.");
}
}
}
}
Now you can unit test InvoiceJob directly and have only a small number of tests for the worker loop.
Why Hosted Services Need Scopes for Scoped Dependencies
Hosted services are registered as singletons. A hosted service is created once and lives for the application lifetime.
This means a hosted service should not directly inject scoped services such as DbContext.
Bad:
public sealed class CleanupWorker : BackgroundService
{
private readonly AppDbContext _context;
public CleanupWorker(AppDbContext context)
{
_context = context;
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
// Bad: a scoped DbContext is captured by a singleton worker.
return Task.CompletedTask;
}
}
Better:
public sealed class CleanupWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public CleanupWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var context = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
await CleanupAsync(context, stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken);
}
}
private static async Task CleanupAsync(
AppDbContext context,
CancellationToken cancellationToken)
{
var oldRows = await context.AuditLogs
.Where(log => log.CreatedAtUtc < DateTime.UtcNow.AddDays(-90))
.ToListAsync(cancellationToken);
context.AuditLogs.RemoveRange(oldRows);
await context.SaveChangesAsync(cancellationToken);
}
}
Each iteration creates its own scope and gets a fresh DbContext.
Unit Tests for Job Logic
The easiest and most valuable tests usually target the job logic service, not the hosted service loop.
Example job:
public sealed class ExpireSubscriptionsJob
{
private readonly AppDbContext _context;
private readonly TimeProvider _timeProvider;
public ExpireSubscriptionsJob(
AppDbContext context,
TimeProvider timeProvider)
{
_context = context;
_timeProvider = timeProvider;
}
public async Task RunOnceAsync(CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
var expiredSubscriptions = await _context.Subscriptions
.Where(s => s.Status == SubscriptionStatus.Active)
.Where(s => s.ExpiresAtUtc <= now)
.ToListAsync(cancellationToken);
foreach (var subscription in expiredSubscriptions)
{
subscription.Status = SubscriptionStatus.Expired;
}
await _context.SaveChangesAsync(cancellationToken);
}
}
Unit or integration-style test:
[Fact]
public async Task RunOnceAsync_WhenSubscriptionIsExpired_MarksItExpired()
{
var now = new DateTimeOffset(2026, 5, 17, 10, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
await using var context = CreateTestDbContext();
context.Subscriptions.Add(new Subscription
{
Status = SubscriptionStatus.Active,
ExpiresAtUtc = now.AddMinutes(-1)
});
await context.SaveChangesAsync();
var job = new ExpireSubscriptionsJob(context, timeProvider);
await job.RunOnceAsync(CancellationToken.None);
var subscription = await context.Subscriptions.SingleAsync();
Assert.Equal(SubscriptionStatus.Expired, subscription.Status);
}
The test runs once, does not wait for real time, and does not start an infinite worker loop.
Fake Time and TimeProvider
Background jobs often depend on time. Direct use of DateTime.UtcNow makes tests harder and can cause flaky behavior.
Hard-to-test code:
var cutoff = DateTime.UtcNow.AddDays(-30);
Better:
var cutoff = _timeProvider.GetUtcNow().AddDays(-30);
Register in production:
builder.Services.AddSingleton(TimeProvider.System);
Use fake time in tests:
var timeProvider = new FakeTimeProvider(
new DateTimeOffset(2026, 5, 17, 10, 0, 0, TimeSpan.Zero));
Benefits:
- Tests are deterministic.
- No dependency on current system time.
- Easier boundary tests.
- No time zone surprises.
- Easier testing of scheduled behavior.
Avoid Real Sleeps in Tests
Tests should not wait for real job intervals.
Bad:
await Task.Delay(TimeSpan.FromMinutes(5));
Bad:
await Task.Delay(5000);
Assert.True(emailSender.EmailWasSent);
Problems:
- Slow tests.
- Flaky tests.
- CI timing issues.
- Race conditions.
- Tests pass locally but fail in CI.
Better options:
- Test job logic directly.
- Use fake time.
- Inject a delay abstraction.
- Run one iteration explicitly.
- Use channels and completion signals.
- Use bounded polling with short timeout.
- Use test-only hooks carefully.
Example bounded polling:
public static async Task EventuallyAsync(
Func<Task<bool>> condition,
TimeSpan timeout,
TimeSpan interval)
{
var deadline = DateTimeOffset.UtcNow.Add(timeout);
while (DateTimeOffset.UtcNow < deadline)
{
if (await condition())
{
return;
}
await Task.Delay(interval);
}
throw new TimeoutException("Condition was not met before timeout.");
}
Use bounded polling for asynchronous integration tests when a background worker really runs.
One-Iteration Job Method
A very testable pattern is to expose one unit of background work as a method.
public interface IOutboxProcessor
{
Task<int> ProcessBatchAsync(CancellationToken cancellationToken);
}
Implementation:
public sealed class OutboxProcessor : IOutboxProcessor
{
private readonly AppDbContext _context;
private readonly IMessagePublisher _publisher;
public OutboxProcessor(
AppDbContext context,
IMessagePublisher publisher)
{
_context = context;
_publisher = publisher;
}
public async Task<int> ProcessBatchAsync(CancellationToken cancellationToken)
{
var messages = await _context.OutboxMessages
.Where(m => m.ProcessedAtUtc == null)
.OrderBy(m => m.CreatedAtUtc)
.Take(50)
.ToListAsync(cancellationToken);
foreach (var message in messages)
{
await _publisher.PublishAsync(
message.Type,
message.Payload,
cancellationToken);
message.ProcessedAtUtc = DateTimeOffset.UtcNow;
}
await _context.SaveChangesAsync(cancellationToken);
return messages.Count;
}
}
Worker:
public sealed class OutboxWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxWorker> _logger;
public OutboxWorker(
IServiceScopeFactory scopeFactory,
ILogger<OutboxWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var processor = scope.ServiceProvider
.GetRequiredService<IOutboxProcessor>();
var count = await processor.ProcessBatchAsync(stoppingToken);
_logger.LogInformation(
"Processed {MessageCount} outbox messages.",
count);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Outbox worker failed.");
}
}
}
}
Tests can focus on ProcessBatchAsync.
Unit Testing Queue Abstractions
A common background pattern is to enqueue work during a request and process it in a hosted service.
Queue interface:
public interface IBackgroundTaskQueue
{
ValueTask QueueAsync(
Func<CancellationToken, ValueTask> workItem,
CancellationToken cancellationToken = default);
ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(
CancellationToken cancellationToken);
}
Channel implementation:
public sealed class BackgroundTaskQueue : IBackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, ValueTask>> _queue;
public BackgroundTaskQueue(int capacity)
{
var options = new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait
};
_queue = Channel.CreateBounded<Func<CancellationToken, ValueTask>>(options);
}
public async ValueTask QueueAsync(
Func<CancellationToken, ValueTask> workItem,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(workItem);
await _queue.Writer.WriteAsync(workItem, cancellationToken);
}
public async ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(
CancellationToken cancellationToken)
{
return await _queue.Reader.ReadAsync(cancellationToken);
}
}
Test:
[Fact]
public async Task QueueAsync_WhenItemIsQueued_DequeueReturnsSameWorkItem()
{
var queue = new BackgroundTaskQueue(capacity: 10);
Func<CancellationToken, ValueTask> workItem =
_ => ValueTask.CompletedTask;
await queue.QueueAsync(workItem);
var dequeued = await queue.DequeueAsync(CancellationToken.None);
Assert.Same(workItem, dequeued);
}
This tests the queue abstraction without running the hosted service.
Testing a Queued Worker
Queued worker:
public sealed class QueuedWorker : BackgroundService
{
private readonly IBackgroundTaskQueue _queue;
private readonly ILogger<QueuedWorker> _logger;
public QueuedWorker(
IBackgroundTaskQueue queue,
ILogger<QueuedWorker> logger)
{
_queue = queue;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var workItem = await _queue.DequeueAsync(stoppingToken);
try
{
await workItem(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Queued work item failed.");
}
}
}
}
Instead of unit testing the infinite loop directly, you can test:
- The queue separately.
- The work item logic separately.
- A small integration test where the worker processes one item and signals completion.
Example completion signal:
[Fact]
public async Task QueuedWorker_WhenWorkItemIsQueued_ExecutesWorkItem()
{
var queue = new BackgroundTaskQueue(capacity: 10);
var logger = NullLogger<QueuedWorker>.Instance;
var worker = new QueuedWorker(queue, logger);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var executed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await worker.StartAsync(cts.Token);
await queue.QueueAsync(_ =>
{
executed.SetResult();
return ValueTask.CompletedTask;
}, cts.Token);
await executed.Task.WaitAsync(cts.Token);
await worker.StopAsync(CancellationToken.None);
}
This test runs the worker but does not depend on arbitrary sleeps.
Integration Tests for Hosted Services
Integration tests can verify that the hosted service is registered, uses real DI, creates scopes correctly, and performs real persistence or messaging behavior.
Example host test:
[Fact]
public async Task Worker_WhenStarted_ProcessesPendingJobs()
{
using var host = Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlite(_connection);
});
services.AddScoped<IInvoiceJob, InvoiceJob>();
services.AddHostedService<InvoiceWorker>();
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IEmailSender, FakeEmailSender>();
})
.Build();
await SeedPendingInvoiceAsync(host.Services);
await host.StartAsync();
await EventuallyAsync(
async () => await InvoiceWasProcessedAsync(host.Services),
timeout: TimeSpan.FromSeconds(5),
interval: TimeSpan.FromMilliseconds(100));
await host.StopAsync();
}
This style gives confidence that the host and DI are wired correctly. Use it sparingly because it is slower and more asynchronous than direct job tests.
WebApplicationFactory and Hosted Services
When using WebApplicationFactory<Program> to test an ASP.NET Core Web API, hosted services registered in the application may start automatically.
This can be good or bad.
Good when:
- The test specifically verifies background processing.
- The worker is part of the scenario.
- The test can control timing and data.
- External dependencies are replaced.
- The worker can shut down cleanly.
Bad when:
- The test only wants to call API endpoints.
- The worker modifies database state unexpectedly.
- The worker calls real external systems.
- The worker runs timers and slows tests.
- The worker introduces flakiness.
- The worker consumes messages from shared queues.
- The worker conflicts with test isolation.
For most API integration tests, replace or remove hosted services unless they are part of the behavior being tested.
Removing Hosted Services in Integration Tests
In WebApplicationFactory, remove hosted services that should not run.
Example:
public sealed class CustomWebApplicationFactory
: WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
var hostedServices = services
.Where(descriptor =>
descriptor.ServiceType == typeof(IHostedService))
.ToList();
foreach (var descriptor in hostedServices)
{
services.Remove(descriptor);
}
});
}
}
This removes all hosted services. If you only want to remove one worker, filter by implementation type.
var descriptor = services.SingleOrDefault(descriptor =>
descriptor.ServiceType == typeof(IHostedService) &&
descriptor.ImplementationType == typeof(InvoiceWorker));
if (descriptor is not null)
{
services.Remove(descriptor);
}
Then test the API without background side effects.
Replacing Hosted Services with Test Doubles
Instead of removing a worker, you can replace it with a test double.
Example:
public sealed class NoOpHostedService : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
Registration in tests:
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(descriptor =>
descriptor.ServiceType == typeof(IHostedService) &&
descriptor.ImplementationType == typeof(InvoiceWorker));
if (descriptor is not null)
{
services.Remove(descriptor);
}
services.AddHostedService<NoOpHostedService>();
});
This keeps host behavior predictable.
Testing API-to-Queue Flow
A common integration test should verify that an API endpoint enqueues work, not that the entire background job completes.
Example endpoint:
app.MapPost("/api/emails", async (
SendEmailRequest request,
IBackgroundTaskQueue queue,
CancellationToken cancellationToken) =>
{
await queue.QueueAsync(async token =>
{
await Task.Delay(1, token);
}, cancellationToken);
return Results.Accepted();
});
Test with fake queue:
public sealed class FakeBackgroundTaskQueue : IBackgroundTaskQueue
{
public List<Func<CancellationToken, ValueTask>> Items { get; } = new();
public ValueTask QueueAsync(
Func<CancellationToken, ValueTask> workItem,
CancellationToken cancellationToken = default)
{
Items.Add(workItem);
return ValueTask.CompletedTask;
}
public ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(
CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
}
Test:
[Fact]
public async Task PostEmail_WhenRequestIsValid_EnqueuesWorkAndReturnsAccepted()
{
var fakeQueue = new FakeBackgroundTaskQueue();
using var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IBackgroundTaskQueue>(fakeQueue);
});
})
.CreateClient();
var response = await client.PostAsJsonAsync("/api/emails", new
{
To = "[email protected]",
Subject = "Welcome"
});
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.Single(fakeQueue.Items);
}
This test is stable because it does not wait for a worker.
Testing the Worker Separately
Test the worker or job processing separately from the endpoint.
[Fact]
public async Task ProcessPendingEmails_WhenEmailExists_SendsEmail()
{
await using var context = CreateDbContext();
context.EmailOutbox.Add(new EmailOutboxMessage
{
To = "[email protected]",
Subject = "Welcome",
Body = "Hello"
});
await context.SaveChangesAsync();
var sender = new FakeEmailSender();
var processor = new EmailOutboxProcessor(context, sender);
await processor.ProcessBatchAsync(CancellationToken.None);
Assert.Single(sender.SentEmails);
}
This separation creates stable tests:
- API test verifies work is requested.
- Job test verifies work is processed.
- A smaller number of integration tests verify the worker loop.
Testing Timed Jobs
Timed jobs should be designed so the timing mechanism is not the only way to run the job.
Bad design:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await DoEverythingAsync(stoppingToken);
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
Better:
public interface IScheduledCleanupJob
{
Task RunOnceAsync(CancellationToken cancellationToken);
}
Worker:
public sealed class ScheduledCleanupWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public ScheduledCleanupWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await using var scope = _scopeFactory.CreateAsyncScope();
var job = scope.ServiceProvider
.GetRequiredService<IScheduledCleanupJob>();
await job.RunOnceAsync(stoppingToken);
}
}
}
Test RunOnceAsync directly. Only use integration tests for the timer loop if necessary.
System.Threading.Timer vs PeriodicTimer
System.Threading.Timer can run callbacks while a previous callback is still executing. This can create overlapping job executions.
Example risk:
Timer interval: 10 seconds
Job duration: 30 seconds
Result: 3 overlapping executions
This can cause:
- Duplicate processing.
- Race conditions.
- Database conflicts.
- Increased load.
- Hard-to-test behavior.
PeriodicTimer works naturally with await, making it easier to avoid overlap:
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await job.RunOnceAsync(stoppingToken);
}
The next iteration does not start until the previous awaited work completes.
For cron-like scheduling, consider a scheduler library or external scheduler, but still keep the job logic testable through a RunOnceAsync method.
Cancellation and Graceful Shutdown
Background jobs must respect cancellation tokens.
Good:
while (!stoppingToken.IsCancellationRequested)
{
await ProcessAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
Bad:
while (true)
{
Thread.Sleep(10000);
Process();
}
Problems with ignoring cancellation:
- Slow shutdown.
- CI tests hang.
- Container termination becomes unsafe.
- Kubernetes or Azure may kill the process before cleanup.
- In-flight work may not finish cleanly.
- Deployments take longer.
- Locked resources may not be released.
Tests should verify that cancellation is honored for important long-running operations.
Example:
[Fact]
public async Task ExecuteAsync_WhenCancellationIsRequested_StopsPromptly()
{
using var cts = new CancellationTokenSource();
var worker = CreateWorker();
await worker.StartAsync(cts.Token);
cts.Cancel();
var stopTask = worker.StopAsync(CancellationToken.None);
await stopTask.WaitAsync(TimeSpan.FromSeconds(2));
}
Avoid tests that can hang forever.
Exception Handling in Background Jobs
Unhandled exceptions in BackgroundService.ExecuteAsync can stop the host depending on host options.
For many production workers, you should decide intentionally:
- Should the whole process stop if the worker fails?
- Should the worker log the error and continue?
- Should the current item be retried?
- Should the item move to a dead-letter queue?
- Should the health check fail?
- Should the application restart?
Example item-level error handling:
try
{
await ProcessMessageAsync(message, stoppingToken);
await _queue.CompleteAsync(message, stoppingToken);
}
catch (TransientException ex)
{
_logger.LogWarning(ex, "Transient failure processing message {MessageId}.", message.Id);
await _queue.AbandonAsync(message, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Permanent failure processing message {MessageId}.", message.Id);
await _queue.DeadLetterAsync(message, ex.Message, stoppingToken);
}
Do not swallow exceptions silently.
Bad:
catch
{
}
At minimum, log with enough context to diagnose the failed item.
BackgroundServiceExceptionBehavior
The host can be configured for how to react to unhandled exceptions from BackgroundService.
Example:
builder.Services.Configure<HostOptions>(options =>
{
options.BackgroundServiceExceptionBehavior =
BackgroundServiceExceptionBehavior.StopHost;
});
Common values:
For critical workers, stopping the host can be better than silently running without the worker. In containerized environments, the orchestrator can restart the process. For non-critical best-effort jobs, you may prefer to handle exceptions inside the loop and continue.
The key is to choose intentionally and monitor it.
Retrying Background Jobs
Background jobs often need retries, but retries must be designed carefully.
Transient failures:
- Network issue.
- Temporary database deadlock.
- Timeout.
- Downstream
503. - Message broker reconnect.
- Rate limiting.
Permanent failures:
- Invalid message schema.
- Missing required business data.
- Unsupported operation.
- Authentication configuration error.
- Bad recipient email format.
Retry strategy:
- Use limited retries.
- Use exponential backoff.
- Add jitter.
- Avoid infinite tight retry loops.
- Preserve failure reason.
- Move poison messages to dead-letter storage.
- Make handlers idempotent.
- Use retry count metadata.
- Avoid duplicate side effects.
Example retry loop concept:
for (var attempt = 1; attempt <= 3; attempt++)
{
try
{
await ProcessAsync(cancellationToken);
return;
}
catch (TransientException) when (attempt < 3)
{
await Task.Delay(
TimeSpan.FromSeconds(Math.Pow(2, attempt)),
cancellationToken);
}
}
In production, use a resilience library or queue-native retry/dead-letter features when appropriate.
Idempotency in Background Jobs
Background jobs may process the same item more than once.
Reasons:
- Worker crashes after side effect but before marking complete.
- Message visibility timeout expires.
- Queue redelivers a message.
- Retry occurs after timeout.
- Two workers compete for the same database row.
- A user submits the same request twice.
Idempotency means processing the same item multiple times produces the same final result.
Example patterns:
- Use unique job IDs.
- Use idempotency keys.
- Mark processed messages in a table.
- Use database unique constraints.
- Use optimistic concurrency.
- Use status transitions.
- Check current state before applying side effects.
- Store external provider transaction IDs.
- Make outgoing writes idempotent when possible.
Example:
var alreadyProcessed = await _context.ProcessedMessages
.AnyAsync(m => m.MessageId == message.Id, cancellationToken);
if (alreadyProcessed)
{
return;
}
await ProcessMessageAsync(message, cancellationToken);
_context.ProcessedMessages.Add(new ProcessedMessage
{
MessageId = message.Id,
ProcessedAtUtc = _timeProvider.GetUtcNow()
});
await _context.SaveChangesAsync(cancellationToken);
Add a unique index on MessageId to protect against races.
Testing Idempotency
An important background job test verifies duplicate processing is safe.
[Fact]
public async Task ProcessMessageAsync_WhenMessageIsProcessedTwice_SendsEmailOnce()
{
await using var context = CreateDbContext();
var emailSender = new FakeEmailSender();
var processor = new EmailMessageProcessor(
context,
emailSender,
new FakeTimeProvider());
var message = new EmailMessage
{
MessageId = "message-123",
To = "[email protected]"
};
await processor.ProcessAsync(message, CancellationToken.None);
await processor.ProcessAsync(message, CancellationToken.None);
Assert.Single(emailSender.SentEmails);
var processedCount = await context.ProcessedMessages.CountAsync();
Assert.Equal(1, processedCount);
}
This catches duplicate side effects.
Queues, Channels, and Backpressure
A background queue should not accept unlimited work without backpressure.
Bad:
private readonly Queue<Func<Task>> _queue = new();
Problems:
- Unbounded memory growth.
- No async waiting.
- Thread-safety issues.
- No backpressure.
- Hard to shut down.
Better:
var options = new BoundedChannelOptions(capacity: 100)
{
FullMode = BoundedChannelFullMode.Wait
};
var channel = Channel.CreateBounded<WorkItem>(options);
Backpressure means producers slow down when the queue is full.
This matters in production because if the background worker cannot keep up, the API should not keep accepting unlimited work into memory.
Tests should verify:
- Enqueue works.
- Dequeue works.
- Cancellation is honored.
- Queue does not drop items unexpectedly.
- Full queue behavior matches design.
- Worker processes items sequentially or with expected concurrency.
Concurrency in Background Jobs
Some workers process one item at a time. Others process multiple items concurrently.
Sequential worker:
Dequeue item
Process item
Complete item
Dequeue next item
Concurrent worker:
Dequeue many items
Process up to N at once
Complete each item
Concurrency can improve throughput but introduces risks:
- Race conditions.
- Duplicate processing.
- Database deadlocks.
- External API rate limits.
- Out-of-order processing.
- Harder tests.
- More complex shutdown.
- More complex error handling.
If using concurrency, make the degree of parallelism explicit and configurable.
Example:
public sealed class WorkerOptions
{
public int MaxDegreeOfParallelism { get; set; } = 4;
}
Tests should verify concurrency-sensitive behavior with deterministic synchronization, not arbitrary sleeps.
Database Polling Workers
Some jobs poll a database table for pending work.
Example:
var jobs = await _context.Jobs
.Where(j => j.Status == JobStatus.Pending)
.OrderBy(j => j.CreatedAtUtc)
.Take(10)
.ToListAsync(cancellationToken);
Risks:
- Two worker instances pick the same row.
- Long transactions block other workers.
- Job status is not updated atomically.
- Retried jobs are not scheduled correctly.
- Failed jobs are stuck forever.
- Polling is too frequent and loads the database.
- Polling is too slow and increases latency.
Safer patterns:
- Atomically claim jobs.
- Use status transitions.
- Use row version or concurrency token.
- Use
LockedUntilUtcor visibility timeout pattern. - Use unique worker ID.
- Keep transactions short.
- Use database indexes.
- Consider a real queue if workload grows.
Testing should include multi-worker or duplicate-claim scenarios if production may run multiple instances.
Message Broker Workers
Message broker workers process messages from systems such as Azure Service Bus, RabbitMQ, Kafka, or storage queues.
Testing levels:
Unit test the handler:
[Fact]
public async Task HandleAsync_WhenOrderCreatedMessageReceived_CreatesInvoice()
{
var handler = CreateHandler();
var message = new OrderCreatedMessage
{
OrderId = 123
};
await handler.HandleAsync(message, CancellationToken.None);
Assert.True(await InvoiceExistsAsync(123));
}
Do not require a real broker for every handler behavior test.
Use real broker tests for:
- Serialization.
- Routing keys/topics/subscriptions.
- Dead-letter behavior.
- Lock renewal.
- Visibility timeout.
- Message completion.
- Consumer registration.
- Retry policy.
- Duplicate delivery behavior.
Outbox Pattern and Testing
The outbox pattern stores messages in the same database transaction as the business data, then a background worker publishes them later.
Example flow:
Create order
Save order and outbox message in same transaction
Outbox worker reads unpublished messages
Publish message
Mark message as published
Benefits:
- Avoids losing events after database commit.
- Gives retryable publishing.
- Supports eventual consistency.
- Keeps API transaction local.
Tests:
- Unit/integration test that command creates outbox message.
- Processor test that unpublished message is published and marked.
- Idempotency test that already published messages are not republished.
- Failure test that publish failure leaves message retryable.
- Integration test that worker processes outbox in the hosted environment.
Example assertion:
Assert.Equal(OrderStatus.Created, order.Status);
Assert.Single(context.OutboxMessages);
Do not rely only on E2E tests for outbox behavior. Most outbox logic can be tested directly.
Health Checks for Background Jobs
A background job can fail while the web API still responds to HTTP requests. Health checks can expose this.
Possible health signals:
- Last successful run time.
- Last failure time.
- Consecutive failure count.
- Queue length.
- Oldest pending item age.
- Worker running status.
- External dependency availability.
- Dead-letter count.
- Processing latency.
- Stuck jobs.
Example state object:
public sealed class WorkerHealthState
{
public DateTimeOffset? LastSuccessUtc { get; set; }
public DateTimeOffset? LastFailureUtc { get; set; }
public int ConsecutiveFailures { get; set; }
}
Tests can verify that the job updates health state on success and failure.
Health checks are especially important when a background job is critical to business operations.
Logging and Observability
Background jobs need strong observability because they often run without direct user interaction.
Log:
- Job name.
- Job instance ID.
- Message ID or job ID.
- Correlation ID.
- Start and end.
- Duration.
- Success/failure.
- Retry count.
- Dead-letter reason.
- Queue length or batch size.
- Number of processed items.
- Exception details.
- External dependency status.
Example:
_logger.LogInformation(
"Processing report job {JobId} for tenant {TenantId}.",
job.Id,
job.TenantId);
Avoid logs like:
_logger.LogInformation("Processing...");
Useful logs make tests and production incidents easier to diagnose.
Testing Logs
Logs are usually not the main behavior to test, but they can be tested when they are part of operational requirements.
Examples:
- Worker logs an error when item processing fails.
- Worker logs a warning when retrying.
- Worker logs critical error before stopping.
- Health state changes after repeated failure.
Test logs with a fake logger or test logging provider.
However, do not over-test exact log message text unless the text is part of a contract. Prefer asserting the log level, event ID, and structured properties when needed.
CI Considerations for Background Job Tests
Background job tests can be flaky if they depend on timing or shared infrastructure.
CI best practices:
- Unit test job logic directly.
- Avoid long real-time intervals.
- Use fake time.
- Use test doubles for queues and external systems.
- Use bounded polling only when necessary.
- Use short timeouts.
- Capture logs on failure.
- Avoid test order dependencies.
- Reset databases and queues between tests.
- Disable hosted services in API tests unless needed.
- Run real broker/container tests in a separate integration stage.
- Limit parallelism for tests sharing broker/database resources.
- Use deterministic test IDs.
- Stop hosts cleanly at test end.
- Avoid infinite loops that cannot be cancelled.
Background tests should never hang CI indefinitely.
Common Mistakes
Common mistakes include:
- Putting all job logic directly inside
ExecuteAsync. - Unit testing an infinite loop instead of the job logic.
- Using
DateTime.UtcNowdirectly instead ofTimeProvider. - Using real sleeps in tests.
- Directly injecting scoped services such as
DbContextinto hosted services. - Not creating a scope per iteration or per message.
- Ignoring cancellation tokens.
- Using
Thread.Sleepin workers. - Swallowing exceptions silently.
- Letting non-critical workers call real external services in integration tests.
- Allowing hosted services to run during unrelated API integration tests.
- Not testing failure paths.
- Not testing duplicate message handling.
- Not making message processing idempotent.
- Using unbounded in-memory queues.
- Not applying backpressure.
- Not testing graceful shutdown.
- Not disposing host or worker resources in tests.
- Depending on test execution order.
- Using shared queues or databases without cleanup.
- Assuming background work completes before assertions.
- Not logging enough context for failed jobs.
- Not having health checks for critical workers.
Best Practices
Separate worker orchestration from job logic.
Expose a RunOnceAsync, ProcessBatchAsync, or message handler method that can be tested directly.
Use BackgroundService for long-running loops.
Keep StartAsync short.
Use PeriodicTimer or scheduler abstractions instead of raw timers when async work must not overlap.
Use TimeProvider for time-dependent logic.
Do not inject scoped services directly into hosted services.
Create a DI scope for each iteration, batch, or message when resolving scoped services.
Pass cancellation tokens through all async calls.
Handle OperationCanceledException correctly during shutdown.
Log exceptions with useful context.
Decide intentionally whether worker failures should stop the host or be handled per item.
Use bounded queues and backpressure.
Design message processing to be idempotent.
Test duplicate processing and failure paths.
Disable or replace hosted services in unrelated WebApplicationFactory tests.
Use integration tests for real DI, database, queue, and hosted-service wiring.
Use real broker/container tests only for behavior that requires real infrastructure.
Avoid arbitrary sleeps in tests.
Use completion signals, fake time, or bounded polling.
Stop and dispose hosts cleanly in tests.
Keep CI background-job tests deterministic, isolated, and time-bounded.