Key Insight – Most .NET background job systems compromise reliability or performance. This article reveals common flaws and presents a robust alternative.
The Critical Role of Background Jobs
Background processing handles essential tasks that shouldn’t block user requests:
- Email/SMS delivery
- Data aggregation
- Batch processing
- Scheduled reports
Poor implementations risk:
- Data loss when jobs disappear during crashes
- Performance degradation from thread starvation
- Unrecoverable failures with no retry mechanism
Why Common Solutions Fail
| Problem Area | Consequences | Real-World Example |
|---|---|---|
| Non-transactional enqueue | Jobs vanish if process crashes mid-execution | Hangfire’s delayed job storage |
| Shared thread pools | Background work starves HTTP requests | Quartz.NET competing with ASP.NET |
| Unhandled failures | Silent job disappearance | MediatR background tasks failing without logs |
Robust Implementation Blueprint
1. Job Definition Structure
public sealed record BackgroundJob(
Guid Id,
string HandlerType, // Full type name for DI resolution
string Payload, // Serialized command data
DateTimeOffset EnqueuedAt,
int RetryCount = 0,
DateTimeOffset? NextAttempt = null);
2. Transactional Job Creation
public async Task<Guid> EnqueueAsync<TCommand>(TCommand command,
CancellationToken ct = default)
{
var job = new BackgroundJob(
Id: Guid.NewGuid(),
HandlerType: typeof(TCommand).AssemblyQualifiedName!,
Payload: JsonSerializer.Serialize(command),
EnqueuedAt: DateTimeOffset.UtcNow);
await using var tx = await _db.BeginTransactionAsync(ct);
await _db.ExecuteAsync(
@"INSERT INTO BackgroundJobs (Id, HandlerType, Payload, EnqueuedAt)
VALUES (@Id, @HandlerType, @Payload, @EnqueuedAt)",
job, tx);
await tx.CommitAsync(ct);
_jobChannel.Writer.TryWrite(job.Id);
return job.Id;
}
3. Scalable Worker Service
public sealed class JobWorker : BackgroundService
{
private readonly ChannelReader<Guid> _reader;
private readonly IServiceProvider _services;
private readonly int _maxDegree;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var semaphore = new SemaphoreSlim(_maxDegree);
var tasks = new List<Task>();
await foreach (var jobId in _reader.ReadAllAsync(stoppingToken))
{
await semaphore.WaitAsync(stoppingToken);
tasks.Add(Task.Run(async () =>
{
try { await ProcessJobAsync(jobId, stoppingToken); }
finally { semaphore.Release(); }
}, stoppingToken));
}
await Task.WhenAll(tasks);
}
private async Task ProcessJobAsync(Guid jobId, CancellationToken ct)
{
await using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IDbConnection>();
var job = await db.QuerySingleOrDefaultAsync<BackgroundJob>(
@"SELECT TOP (1) * FROM BackgroundJobs WITH (UPDLOCK, READPAST)
WHERE Id = @Id AND (NextAttempt IS NULL OR NextAttempt <= SYSDATETIMEOFFSET())",
new { Id = jobId }, ct);
if (job is null) return;
var handler = CreateHandler(scope.ServiceProvider, job);
var command = DeserializePayload(job);
try
{
await handler.HandleAsync(command, ct);
await CompleteJobAsync(db, job.Id, ct);
}
catch (Exception ex)
{
await HandleFailureAsync(db, job, ex, ct);
}
}
}
Architectural Advantages
- Guaranteed persistence – Jobs survive process crashes
- Isolated execution – Dedicated worker threads prevent ASP.NET interference
- Controlled retries – Exponential backoff with jitter
- Horizontal scaling – Database locking enables multi-instance deployment
Performance Optimizations
| Technique | Impact |
|---|---|
| Channel-based queuing | Reduces database polling |
| Async semaphore | Maintains optimal concurrency |
| Connection reuse | Minimizes connection overhead |
| Skipped locks | Allows parallel job processing |
Alternative Solutions Guide
| Requirement | Recommended Approach |
|---|---|
| Sub-millisecond latency | MemoryChannel with persistent fallback |
| 50k+ jobs/second | Dedicated broker (Kafka/RabbitMQ) |
| Cross-service transactions | Outbox pattern with CDC |
Deployment Checklist
- [ ] Configure database locks (UPDLOCK, READPAST)
- [ ] Set concurrency limits based on workload
- [ ] Implement dead-letter monitoring
- [ ] Add Prometheus metrics/health checks
- [ ] Establish retry policies with jitter
Common Questions
Q: How does this improve on Hangfire?
A: Guarantees job persistence before execution and prevents thread pool exhaustion.
Q: PostgreSQL compatibility?
A: Replace SQL Server hints with FOR UPDATE SKIP LOCKED.
Q: Throughput limits?
A: Capable of ~5k jobs/sec on modest hardware. Beyond that, consider a message broker.
Final Recommendation
This pattern delivers:
- Reliability through transactional storage
- Performance via proper thread isolation
- Maintainability with clear error handling
- Scalability using database-backed queues
It satisfies the requirements for most .NET applications while avoiding common pitfalls found in off-the-shelf solutions.


